A CLEAR PATH FROM IDEA TO INTEGRATION
Let’s get you building.
One clean API over M-Pesa, Airtel Money, and T-Kash. A sandbox that behaves like the real thing — cancellations, timeouts, duplicate callbacks and all. Webhooks you can actually trust.
https://api.njia.dev/v1REST · JSON
Quickstart
Your first payment in under two minutes. Amounts are always in
minor units (KES cents): 150000 = KSh
1,500. The paved path is
npm install @njia/sdk for your code
and npx @njia/cli listen for local
webhooks — but everything below works with plain curl too.
# Create a workspace at /app/ to get your test key
curl http://localhost:4400/v1/payments \
-H "Authorization: Bearer sk_test_njia_..." \
-H "Idempotency-Key: order-42" \
-H "content-type: application/json" \
-d '{"channel":"mpesa","amount":150000,"phone":"254700000000","reference":"ORDER-42"}'
The response comes back pending immediately — mobile
money is asynchronous, always. A couple of seconds later the sandbox
resolves it and a payment.succeeded event fires. Watch it
live on the dashboard or with
njia listen.
Authentication
Sign up on the dashboard (email + password, no business documents) and
you get a workspace with a sk_test_… key immediately.
One login, many workspaces: create one per product or
client from the switcher in the dashboard — each has its own API keys,
payments, webhooks, KYC status, and M-Pesa/Airtel credentials, fully
isolated. Workspaces belong to the business, not to whoever built the
integration: members hold roles (owner, admin, developer), so an
agency can build inside a client's workspace and hand it over cleanly.
Sensitive actions — activating live mode, storing provider
credentials, minting live keys — need owner or admin. On the API, keys
already carry their workspace; dashboard sessions select one with the
Njia-Account header. Every API request carries a secret
key as a Bearer token:
Authorization: Bearer sk_test_njia_...
Keys are hashed at rest — the full key is shown exactly once, at creation. Lost it? Revoke and mint a new one from the dashboard. Keep keys server-side, never in a browser or mobile app.
Two-factor authentication
Turn it on under Keys & connections → Two-factor authentication: scan the QR code with any authenticator app (Google Authenticator, 1Password, Authy), confirm a code, and save the 10 one-time recovery codes. From then on, signing in to the dashboard asks for a 6-digit code after your password. It protects the dashboard sign-in only — API keys are unaffected, so integrations keep running. Turning it on signs out your other devices.
Lost your phone? Sign in with a recovery code, then generate a new set (or turn two-factor off and on again with a new device).
Test mode and live mode
sk_test_… keys hit the sandbox;
sk_live_… keys move real money. The two are fully
partitioned — payments, subscriptions, events, and webhook endpoints
each exist per-mode, so a test webhook can never receive a live event.
Live mode unlocks after activation on the dashboard (business name
now, progressive verification in production) and starts with a KSh
50,000 rolling 30-day cap. Live payments run through
your own Daraja credentials, stored per-account in Settings →
M-Pesa; C2B deposits are routed to your account by paybill shortcode.
Test numbers
The sandbox's magic numbers reproduce the failure modes you will meet in production. Test against all of them before you ship.
| Phone | Behaviour | What it tests |
|---|---|---|
254700000000 |
Succeeds after ~2s | The happy path |
254700000001 |
User cancels (code 1032) |
Your "payment failed" UX |
254700000002 |
DS timeout after ~8s (code 1037) |
Phones that are off or unreachable |
254700000003 |
Insufficient funds (code 1) |
Retry prompts |
254700000004 |
Succeeds slowly (~10s) | Impatient polling, double-submits |
254700000005 |
Succeeds; webhook delivered twice | Idempotent webhook consumers |
Any other valid 2547XXXXXXXX /
2541XXXXXXXX number succeeds after ~2s.
Payments
| Field | Type | Notes |
|---|---|---|
channel |
string |
mpesa (default), airtel_money —
live-ready. tkash — sandbox only for now
|
amount |
integer | Minor units. 150000 = KSh 1,500 |
phone |
string | MSISDN: 254712345678 |
reference |
string | Your own order/invoice ref (optional) |
Idempotency
Send an Idempotency-Key header on every create. Network
died mid-request? Retry with the same key and you get the
original payment back — never a double charge. Reusing a key
with a different body returns 409, loudly.
Lifecycle
pending → succeeded or pending → failed.
Never trust the create response as final — the truth arrives by
webhook (or poll GET/v1/payments/:id if you must).
Webhooks
Register an endpoint; we POST every event to it, signed:
curl http://localhost:4400/v1/webhook_endpoints \
-H "Authorization: Bearer sk_test_..." -H "content-type: application/json" \
-d '{"url":"https://your-app.co.ke/webhooks"}'
# → { "id": "we_…", "secret": "whsec_…" } ← store the secret
Verify every delivery
Each POST carries
Njia-Signature: t=<unix>,v1=<hmac> — an
HMAC-SHA256 of t + "." + rawBody with your endpoint
secret. Verify before trusting:
import { verifyWebhookSignature } from "@njia/sdk";
app.post("/webhooks", (req, res) => {
const ok = verifyWebhookSignature({
payload: req.rawBody, // the RAW body string
header: req.headers["njia-signature"],
secret: process.env.NJIA_WEBHOOK_SECRET,
});
if (!ok) return res.status(400).end();
// handle the event — idempotently! Check event.id against ones you've seen.
res.status(200).end();
});
254700000005). Track processed event.ids and
ignore repeats.
Retries
Non-2xx or unreachable? We retry automatically (5s, then 30s in the
sandbox; production schedules stretch to days). Every attempt — status
code, latency, error — is in the delivery log on the dashboard and at
GET/v1/events/:id/deliveries.
Events & replay
Events are immutable facts; deliveries are attempts against them. The event types:
| Type | Fires when |
|---|---|
payment.pending |
A payment is created |
payment.succeeded |
Funds confirmed (receipt attached) |
payment.failed |
Cancelled, timed out, or declined |
subscription.created |
A subscription starts |
subscription.charged |
A billing cycle initiates its payment |
subscription.renewed |
A cycle's payment succeeded; next cycle scheduled |
subscription.past_due |
A cycle's payment failed; billing paused |
subscription.canceled |
You cancelled it |
Missed something during downtime? POST/v1/events/:id/replay redelivers to all endpoints — or
click Replay webhook on the dashboard.
Subscriptions
Recurring billing over mobile money — the thing almost nobody offers cleanly.
curl http://localhost:4400/v1/subscriptions \
-H "Authorization: Bearer sk_test_..." -H "content-type: application/json" \
-d '{"amount":99900,"phone":"254700000000","interval":"month","reference":"GOLD-PLAN"}'
| Field | Notes |
|---|---|
amount |
Minor units per cycle |
interval |
day · week · month — plus
minute, sandbox-only, so you can watch cycles fire
live
|
strategy |
stk_cycle (default) or ratiba — see
below
|
charge_immediately |
false to start billing one interval from now
(default charges on creation)
|
Cancel with DELETE/v1/subscriptions/:id. A failed cycle marks the
subscription past_due and pauses billing; fix the issue,
then POST/v1/subscriptions/:id/retry.
"interval":"minute" and phone 254700000000,
then watch the dashboard feed. You'll see
charged → payment.succeeded → renewed roll by every
minute. Cancel when you've seen enough.
stk_cycle vs ratiba
stk_cycle |
ratiba |
|
|---|---|---|
| How it works | Njia schedules each cycle and fires an STK push; the customer approves each charge on their phone | M-Pesa's native standing order (Ratiba): one-time setup, then M-Pesa charges automatically |
| Customer friction | PIN entry every cycle | None after setup |
| Works today | Yes, on any paybill | Requires Ratiba product approval on your shortcode |
| Failure handling | Njia marks past_due, you retry |
M-Pesa notifies via callback |
providers/daraja.ts against the current portal before
go-live.
CLI — @njia/cli
Webhooks on localhost without ngrok. Install globally, or run it with zero install via npx:
npm install -g @njia/cli # or: npx @njia/cli listen …
export NJIA_API_KEY=sk_test_njia_...
njia listen --forward-to http://localhost:3000/webhooks
njia trigger 254700000001 # simulate a user cancelling the prompt
njia payments # recent payments, coloured by status
How it works: njia listen opens an outbound SSE
connection to /v1/stream — your machine dials out, so
nothing on the internet ever needs to reach your laptop. Each event is
printed live, then re-signed and POSTed to your
--forward-to URL as a normal
Njia-Signature webhook, with per-delivery status and
latency shown inline. Your local handler runs the exact verification
code it will run in production.
21:27:25 payment.pending pay_8485xpt0… 1500.00 KES · 254700000000
↳ 200 14ms POST http://localhost:3000/webhooks
21:27:27 payment.succeeded pay_8485xpt0… 1500.00 KES · 254700000000
↳ 200 7ms POST http://localhost:3000/webhooks
NJIA_API_KEY (required),
NJIA_BASE_URL (defaults to the hosted API),
NJIA_LOCAL_WEBHOOK_SECRET (signing secret for forwarded
events, default whsec_local_dev).
SDK — @njia/sdk
npm install @njia/sdk
Fully typed TypeScript, ships dual ESM + CommonJS builds with
declarations — works in import and
require() codebases alike, zero runtime dependencies,
Node ≥ 18.
import { Njia } from "@njia/sdk";
const njia = new Njia(process.env.NJIA_API_KEY);
// one-off collection
const payment = await njia.payments.create(
{ channel: "mpesa", amount: 150000, phone: "254712345678", reference: "ORDER-42" },
{ idempotencyKey: "order-42" }
);
// recurring
const sub = await njia.subscriptions.create({
amount: 99900, phone: "254712345678", interval: "month", reference: "GOLD-PLAN",
});
// webhook verification (raw body, not parsed JSON)
import { verifyWebhookSignature } from "@njia/sdk";
const ok = verifyWebhookSignature({
payload: rawBody, header: req.headers["njia-signature"], secret: process.env.NJIA_WEBHOOK_SECRET,
});
Failed API calls throw a typed NjiaError carrying
status, type, and message —
catch it and branch on
type (idempotency_error,
live_cap_exceeded, …) rather than parsing strings.
Available surface: payments, subscriptions,
webhookEndpoints, events (including
events.replay(id)).
Going live with Daraja
Sandbox and live mode share every route — only the provider behind them changes. Flip with environment variables:
NJIA_MODE=live
DARAJA_CONSUMER_KEY=... # from developer.safaricom.co.ke
DARAJA_CONSUMER_SECRET=...
DARAJA_SHORTCODE=600XXX # your paybill / till
DARAJA_PASSKEY=... # Lipa na M-Pesa Online passkey
DARAJA_ENV=production # or sandbox (Safaricom's)
NJIA_PUBLIC_URL=https://api.yourapp.co.ke # where Daraja posts callbacks
What happens in live mode:
| Njia | Daraja |
|---|---|
POST/v1/payments
|
OAuth (cached ~1h) → stkpush/v1/processrequest;
CheckoutRequestID stored as
provider_ref
|
/v1/callbacks/daraja/stk/{token} |
Receives Body.stkCallback;
ResultCode 0 → succeeded + receipt,
else mapped failure (1032 cancel,
1037 timeout, 1 no funds…)
|
/v1/callbacks/daraja/c2b/confirmation/{token}
|
Direct paybill deposits become succeeded payments
with source: "c2b" — dedup'd by
TransID
|
/v1/callbacks/daraja/ratiba/{token} |
Standing-order notifications surface as
standing_order.notification events
|
Your callback URLs
Saving your credentials mints a callback token unique to your workspace. The exact URLs to register in the Daraja portal are returned by the save call and shown in Settings → M-Pesa, or fetch them any time:
curl https://api.njia.dev/auth/callback_urls -H "Authorization: Bearer ses_..."
{"object":"callback_urls","data":{"mpesa":{
"stk": "https://api.njia.dev/v1/callbacks/daraja/stk/cbk_f9c897e4…",
"c2b_validation": "…/v1/callbacks/daraja/c2b/validation/cbk_f9c897e4…",
"c2b_confirmation": "…/v1/callbacks/daraja/c2b/confirmation/cbk_f9c897e4…",
"ratiba": "…/v1/callbacks/daraja/ratiba/cbk_f9c897e4…"}}}
BusinessShortCode disagrees
with the token's configured shortcode is rejected outright. If a URL
leaks, POST /auth/callback_tokens/rotate with
{"channel":"mpesa"} — then re-register immediately,
because the old URL stops resolving the moment you rotate.
Airtel Money
Add your OpenAPI client ID and secret (from developers.airtel.africa)
in Settings → Airtel Money, then
"channel": "airtel_money" on any payment or subscription.
Live payments send a USSD push through
/merchant/v1/payments/; Njia sets the transaction id to
the payment id, so Airtel's callback to
/v1/callbacks/airtel/{token} self-routes. Status mapping:
TS → succeeded (with airtel_money_id as the
receipt), TF → failed, and TIP/TA
deliberately leave the payment pending — ambiguous is not
success, and the status-query endpoint resolves it rather than
guessing. Amounts convert to whole shillings and MSISDNs to local
format automatically; your webhook handler sees the exact same event
shapes as M-Pesa. Airtel has no native standing orders, so
subscriptions on this channel always use stk_cycle.
registerC2bUrls(), put the Daraja callback
routes behind Safaricom's published IP ranges as defence in depth on
top of the callback token, and swap SQLite + timers for Postgres + a
job queue. The prototype's shape survives; the infrastructure hardens.
See PRODUCTION.md in the repo.
Errors
Errors are JSON, always the same shape:
{ "error": { "type": "invalid_request_error", "message": "amount must be a positive integer…" } }
| HTTP | Type | Meaning |
|---|---|---|
| 400 | invalid_request_error |
Malformed input — the message says exactly what |
| 401 | authentication_error |
Missing/invalid API key |
| 404 | not_found |
No such object on your account |
| 409 | idempotency_error |
Key reused with a different body |
Payment failures are not errors — they're successful API
calls whose payment ended in status: "failed" with a
failure_code you can show the customer.