M-Pesa works beautifully in the happy path. A customer enters their PIN, money moves, everyone celebrates. Then production happens — a callback arrives twice, a STK push times out while the payment actually succeeded, and your finance team is reconciling three spreadsheets because the API response and the webhook disagree.
We learned these lessons building payment flows for Uboraschool and Anza Connect — school fees and SME invoicing across M-Pesa, Tigo Pesa, and Airtel Money. Here's what the Safaricom docs don't emphasise enough.
1. Timeouts are not failures
STK push requests can time out on your side while the transaction completes on the customer's phone. If you treat a timeout as a failed payment, you'll double-charge or leave money in limbo.
Our pattern: every payment intent gets a client-generated idempotency_key stored before the API call. On timeout, we poll the transaction status endpoint — never retry the push blindly.
// Never assume timeout = failure
if (response.status === 'timeout') {
return await pollTransactionStatus(checkoutRequestId);
}
2. Duplicate callbacks will happen
Safaricom's IPN (Instant Payment Notification) can deliver the same callback more than once. Network retries, load balancer quirks, your own server restarting mid-request — it all happens.
- Store every callback payload with its
TransIDas a unique constraint - Return HTTP 200 even on duplicates — Safaricom will keep retrying otherwise
- Process payments idempotently: second delivery updates nothing, logs a warning
If your webhook handler isn't safe to run twice, it isn't production-ready.
3. Reconciliation is the real product
The integration code is maybe 20% of the work. The other 80% is making sure your ledger matches M-Pesa's settlement report at end of day. Discrepancies show up when:
- A customer pays but closes the browser before your callback fires
- Partial reversals happen on B2C payouts
- Your sandbox behaviour differs subtly from production tariff codes
Uboraschool runs a nightly reconciliation job that compares our transaction table against the Daraja query API. Mismatches get flagged for human review — not auto-corrected.
4. Test with real phones, not just Postman
Sandbox is useful for wiring up endpoints. It won't teach you how STK behaves on a Tecno Spark with 2G fallback, or what happens when a user has insufficient float but the UI already showed "processing."
We keep a drawer of test SIMs with known balance states. Every fintech project gets a test matrix: success, cancel, timeout, insufficient funds, and duplicate callback simulation.
What we ship with every M-Pesa integration
Idempotent webhook handlers. A reconciliation dashboard. Runbooks for the three most common support tickets. And honest documentation about what happens when things go wrong — because in mobile money, they will.
Building something that touches M-Pesa? Tell us what you're shipping — we've reconciled the hard way so you don't have to.