# M-Pesa Daraja Architecture: STK Push, Callbacks, and Reconciliation

> Safaricom Daraja as an async payment control plane: OAuth tokens, STK Push, callback races and idempotency, C2B vs B2C, daily reconciliation.

- Canonical: https://thearchitecturedeepdive.blog/articles/daraja-mpesa-architecture.html
- Series: The Architecture DeepDive, No. 021
- Author: Barnabas Waweru
- Published: 2026-08-26
- Tags: Payments
- Cite as: Barnabas Waweru, "M-Pesa Daraja Architecture: STK Push, Callbacks, and Reconciliation", The Architecture DeepDive, https://thearchitecturedeepdive.blog/articles/daraja-mpesa-architecture.html

---

STK · Callback · Ledger

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

## 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

-   [Safaricom Daraja developer portal](https://developer.safaricom.co.ke/)
-   [Daraja API documentation (Authorization, Express, C2B, B2C, Transaction Status)](https://developer.safaricom.co.ke/Documentation)

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