njia./Documentation
Developers / API reference v1

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.

BASE URLhttps://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

POST/v1/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();
});
Mobile money delivers duplicates. Real providers send the same callback more than once. So do we (deliberately, via 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.

POST/v1/subscriptions
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.

Try it now: create a subscription with "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
Ratiba is Daraja's newest API (announced late 2024) and Safaricom's docs are still stabilising — verify field names in 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
The stream is a live tap, not the delivery mechanism: registered webhook endpoints get retried deliveries independently, so closing your laptop loses nothing. Config: 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…"}}}
Treat these like API keys. Njia authenticates an inbound provider callback by its token — that is what stops anyone who knows your paybill number (it is printed on your receipts) from forging a settlement. The token identifies the tenant; the body never does. A C2B payload whose 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.

Before production traffic: register your C2B URLs once via 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.