Payments · Daraja · M-Pesa
STK · Callback · Ledger
M-PESA

M-Pesa Daraja Architecture

Daraja is an asynchronous payment API. Your server asks Safaricom to prompt a phone. A later HTTP POST tells you what the customer did. If you treat the first JSON as a paid order, you will ship product on a pending PIN entry.

Barnabas Waweru 26 August 2026 16 min read
ansi · wordmark · daraja
██████╗  █████╗ ██████╗  █████╗      ██╗ █████╗ 
██╔══██╗██╔══██╗██╔══██╗██╔══██╗     ██║██╔══██╗
██║  ██║███████║██████╔╝███████║     ██║███████║
██║  ██║██╔══██║██╔══██╗██╔══██║██   ██║██╔══██║
██████╔╝██║  ██║██║  ██║██║  ██║╚█████╔╝██║  ██║
╚═════╝ ╚═╝  ╚═╝╚═╝  ╚═╝╚═╝  ╚═╝ ╚════╝ ╚═╝  ╚═╝

The Control Plane

OAuth
Client credentials. Cache the token. One app, one host (sandbox vs production).
STK Push
You initiate a prompt. Paid is the later callback (or query) with a receipt.
C2B
Customer starts at Paybill or Till. You register confirmation (and optional validation) URLs.
B2C
Money leaves the shortcode. Separate ledger type, separate result URL, stricter authz.

What Daraja is

Safaricom's Daraja (developer portal: developer.safaricom.co.ke) is the HTTP interface to M-Pesa business APIs: authorization, M-Pesa Express (STK Push / Lipa Na M-Pesa Online), Customer to Business, Business to Customer, transaction status, account balance, and reversals. Sandbox and production are different hosts. Go-live is an approval process, not a config flag.

This article describes the architecture those APIs force on your backend. It does not quote tariffs, success rates, or latency SLAs. Those belong on Safaricom's current commercial pages, which change.

schema · STK path
CUSTOMER                 YOUR API                  DARAJA
  tap Pay          POST /checkout
                     insert payment (pending)
                     get OAuth token (cached)
                     POST stkpush/v1/processrequest ──►
                     store CheckoutRequestID
                     202 accepted
                                               prompt on handset
  enter PIN ──────────────────────────────────────────►
                                               POST CallBackURL
                     verify source + idempotency
                     ResultCode 0 → mark paid
                     else → mark failed
                     200 OK (always, if you parsed it)

OAuth Token Lifecycle

Client credentials, short-lived

Documented flow: GET /oauth/v1/generate?grant_type=client_credentials with HTTP Basic (consumer key as username, consumer secret as password). The JSON body includes access_token and expires_in (commonly advertised as 3599 seconds). Cache the token server-side. Do not mint a token on every STK call.

Scope the cache per shortcode / app. A token from sandbox does not work on production. A token from app A does not authorize app B.

TypeScript · token cache
type Token = { value: string; expiresAt: number };

let cached: Token | null = null;

async function accessToken(): Promise<string> {
  const now = Date.now();
  if (cached && now < cached.expiresAt - 60_000) return cached.value;
  const basic = Buffer.from(`${key}:${secret}`).toString("base64");
  const res = await fetch(
    `${host}/oauth/v1/generate?grant_type=client_credentials`,
    { headers: { Authorization: `Basic ${basic}` } }
  );
  const body = await res.json() as { access_token: string; expires_in: string };
  cached = {
    value: body.access_token,
    expiresAt: now + Number(body.expires_in) * 1000,
  };
  return cached.value;
}
Refresh before expiry, not after 401 storms

Leave a safety margin (a minute is typical) so a burst of STK requests does not stampede the OAuth endpoint when the token is one second from death. Serialize refresh with a lock.

STK Push Is Asynchronous

Initiate ≠ paid

You POST to /mpesa/stkpush/v1/processrequest with a password that is Base64(Shortcode + Passkey + Timestamp), the customer MSISDN in 2547… form, amount, account reference, and a public HTTPS CallBackURL. A successful initiate returns identifiers including CheckoutRequestID and MerchantRequestID. That is an accepted prompt, not a receipt.

The customer may enter a PIN, cancel, ignore the prompt, or time out. Those outcomes arrive later on the callback (and can be polled via the STK query API if the callback never comes).

1
Create local payment
Insert a pending row with your own id before you call Daraja. You need a place to hang CheckoutRequestID.
2
STK initiate
Bearer token. Persist CheckoutRequestID on the row. Return 202 to your client.
3
Customer + PIN
Happens off your servers. Do not poll the handset. Wait for callback or query.
4
Callback or query
ResultCode 0 plus MpesaReceiptNumber is the paid signal. Anything else is a documented fail or cancel.
mermaid · sequence
sequenceDiagram
  participant U as Customer
  participant A as Your API
  participant D as Daraja
  U->>A: Pay
  A->>D: STK processrequest
  D-->>A: CheckoutRequestID
  A-->>U: pending
  D->>U: STK prompt
  U->>D: PIN or cancel
  D->>A: POST CallBackURL
  A-->>D: 200
  A-->>U: paid or failed (websocket / poll)

Callbacks: Races and Idempotency

The POST can arrive twice, late, or never

Official and operator practice: respond with HTTP 200 once you have accepted the body, or Safaricom may retry. Retries mean the same CheckoutRequestID can hit you more than once. Apply the receipt with a unique constraint on CheckoutRequestID or MpesaReceiptNumber. The second apply is a no-op, not a second fulfillment.

A race you will hit: the customer-facing poller and the callback handler both try to mark paid. Use one state machine (pending → paid | failed) with conditional updates. Do not increment inventory in both paths.

TypeScript · idempotent apply
async function applyStkCallback(body: StkCallback) {
  const cb = body.Body.stkCallback;
  const paid = cb.ResultCode === 0;
  const receipt = paid
    ? cb.CallbackMetadata.Item.find((i) => i.Name === "MpesaReceiptNumber")?.Value
    : null;

  await db.query(
    `UPDATE payments
        SET status = $2, receipt = $3, raw_callback = $4
      WHERE checkout_request_id = $1
        AND status = 'pending'`,
    [cb.CheckoutRequestID, paid ? "paid" : "failed", receipt, body]
  );
}
Callback never arrived

Network partitions happen. After a timeout you choose, call the STK query endpoint with the CheckoutRequestID. Treat query and callback as two inputs to the same state machine. Do not open a second payment row.

C2B versus B2C

API Direction Typical use Your job
STK Push (Express) Customer pays you Checkout prompt Initiate + callback + query fallback
C2B Customer pays you Paybill / Till, customer-initiated Register URLs, validate (optional), confirm
B2C You pay customer Payout, refund, disbursement Initiate + result URL, stronger authz

Do not reuse the STK state machine for B2C

B2C is money leaving the shortcode. The failure modes are different (insufficient organization balance, invalid MSISDN, result URL timeout). Store B2C as a distinct ledger type. A confused "payment" table that mixes inbound STK and outbound B2C will reconcile incorrectly.

Security Model (No Webhook Signature)

Daraja callbacks are not Stripe-style HMAC

Public Daraja documentation does not give you a per-payload signature header the way many card processors do. Your defenses are therefore boring and mandatory:

  • HTTPS only on the callback URL. No open HTTP.
  • Unpredictable path or shared secret query is not enough. Prefer network allowlists for the source IPs Safaricom publishes, plus application-level checks (CheckoutRequestID you minted, amount match, MSISDN match).
  • Never trust a callback that does not match a pending row you created. An attacker who POSTs a fake ResultCode 0 at a guessable URL should hit zero rows.
  • Do not put consumer secret, passkey, or tokens in the repo or the browser. Server only.
Confirmation is not authentication of the caller

If you cannot cryptographically verify the POST, you verify it against your own pending intent. The CheckoutRequestID you stored is the capability. A callback without that id is noise.

Daily Reconciliation

Your table is not the source of truth

M-Pesa's ledger is. Your payments table is a projection. Once a day (or more often if volume requires), pull transaction status / official statements for the shortcode and diff:

  • Paid locally, missing remotely: investigate before you treat it as revenue.
  • Paid remotely, pending locally: apply the receipt (callback loss).
  • Amount or MSISDN mismatch: freeze fulfillment, do not auto-fix.

Reversals exist as their own API. A paid row can become reversed later. Model that as a new state, not a delete.

mermaid · flowchart
flowchart TD
  A[Local payments] --> D{Diff}
  B[Daraja status / statement] --> D
  D -->|local only| E[Investigate / do not fulfill]
  D -->|remote only| F[Apply receipt]
  D -->|match| G[Close day]
  D -->|mismatch| H[Freeze + human]

Key Takeaways

Principles

  1. Initiate is not paid. CheckoutRequestID is a prompt id. MpesaReceiptNumber is money.
  2. Cache the OAuth token per app, with a refresh margin.
  3. Write the pending row first, then call STK. Callbacks need something to update.
  4. Callbacks retry. Unique CheckoutRequestID / receipt. One state machine.
  5. Query when the callback is late. Same apply function.
  6. C2B, STK, and B2C are different ledgers. Do not crush them into one status enum.
  7. There is no standard HMAC on the callback. Match against your pending intent and official source constraints.
  8. Reconcile against Safaricom, not against your own optimism. Reversals are a later state.

Official Documentation

Primary sources

Read the live portal for request fields, result codes, and IP guidance. Do not copy fees or uptime claims from blogs into production runbooks.