guide · payments
M-PESA Callbacks Keep Breaking? A Daraja Reconciliation Field Guide
Every Daraja tutorial ends at "payment received." Production starts after that — where callbacks go missing, arrive twice, or claim success your database never saw. I've inherited enough broken M-PESA integrations to know exactly where they die. Here's the checklist I run on every payment system I ship or rescue.
The failure modes nobody demos
- The silent callback. Safaricom fires the STK callback but your server is down, DNS hiccups, or the endpoint 500s. No retry arrives in time. The customer paid; your system thinks they didn't.
- The double-tap. Safaricom retries a callback that timed out — and your handler processes it again. Double-credited orders, double-activated subscriptions.
- The token race. Two concurrent payments each fetch an OAuth token; one overwrites the other's cache entry. Intermittent 401s that "can't be reproduced."
- The abandoned prompt. User gets the STK prompt and walks away. Payment sits
pendingforever, and your dashboard says "awaiting payment" for a transaction that died a week ago. - The sandbox special. Works perfectly in sandbox, fails silently in production because callback URLs, IPs or shortcodes differ.
Rule 1: never trust a single signal
A callback is a claim, not proof. Before marking anything paid:
- Match
CheckoutRequestID(andMerchantRequestID) against a payment record you created at initiation. Unknown IDs get logged and rejected — never auto-created. - Verify the
ResultCode. Anything non-zero is a failed payment, not a pending one. Record the code — 1032 (cancelled), 1037 (timeout), 2001 (wrong PIN) tell you why. - For C2B, validate on the
validationendpoint before accepting — wrong account references get rejected before the money moves, not reconciled after.
Rule 2: idempotency or it didn't happen
Safaricom will send the same callback more than once. Your handler must be safe to run twice:
// Only transition from expected states — never blind-update
$payment = Payment::where('checkout_request_id', $id)
->where('status', 'pending') // ← the idempotency guard
->first();
if (!$payment) {
// already processed, or unknown — log and still return 200
return response()->json(['ResultCode' => 0]);
}
Same for the action the payment triggers — order creation, subscription activation, boost application. If the callback runs twice, the side effect must not.
Rule 3: always answer 200 — then fix it out of band
Your callback endpoint's job is to acknowledge, fast. Do the heavy work (receipts, notifications, downstream calls) on a queue. If your handler throws a 500, Safaricom may stop retrying — return 200, log the failure, and let the queue retry where it can.
Rule 4: timeouts need a second opinion
When the callback never arrives, ask Safaricom directly. The Transaction Status Query API exists for exactly this:
- Queue a status query ~60–90s after STK initiation for anything still pending.
- If the query says success but no callback arrived — process it through the same idempotent path.
- If it says failed/cancelled — close the payment with the real reason, not "timeout."
This one job converts most "M-PESA is broken" complaints into correct outcomes.
Rule 5: reconciliation is a feature, not an afterthought
Every payment system needs a view that answers three questions without a database login:
- What did Safaricom charge? — matched by receipt number.
- What did we record? — orders/payments in our DB.
- Where do they disagree? — the flagged list a human actually checks.
I build this as a first-class admin screen on every payment project — because month-end "the numbers don't match" is not a bug report anyone should get.
Rule 6: reversals and B2C are payments too
Reversals arrive as callbacks of their own — handle them explicitly (flag, notify, reconcile) or they'll silently corrupt the ledger. B2C payouts need the same discipline: result callbacks, timeout callbacks, and a state machine that can't pay twice.
The shortest version: match IDs you created, treat callbacks as claims, make every handler idempotent, return 200 fast, query status on timeouts, and build reconciliation where a human can see it. Do those six and M-PESA stops being the scary part of the stack.
This is the same discipline running in Kenya Escrow, Shops in Kenya and NunuaChai. If your integration is already misbehaving — stuck payments, missing callbacks, mismatched numbers — I do integration rescue work, or send me the symptoms and I'll tell you what's actually wrong.