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.
██████╗ █████╗ ██████╗ █████╗ ██╗ █████╗ ██╔══██╗██╔══██╗██╔══██╗██╔══██╗ ██║██╔══██╗ ██║ ██║███████║██████╔╝███████║ ██║███████║ ██║ ██║██╔══██║██╔══██╗██╔══██║██ ██║██╔══██║ ██████╔╝██║ ██║██║ ██║██║ ██║╚█████╔╝██║ ██║ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚════╝ ╚═╝ ╚═╝
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.
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)
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.
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;
}
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.
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).
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)
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.
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]
);
}
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.
| 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 |
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.
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:
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.
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:
Reversals exist as their own API. A paid row can become reversed later. Model that as a new state, not a delete.
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]
Read the live portal for request fields, result codes, and IP guidance. Do not copy fees or uptime claims from blogs into production runbooks.