M-Pesa Daraja integrations leak money in two directions. They double-count payments when Safaricom retries a callback your handler has already processed, and they lose payments when a callback never arrives at all. Both failures come from the same assumption: that the callback is a reliable, once-only event. It is neither, and the integration has to be built as if it is neither.
This is the second-order problem. Getting an STK push to fire is a weekend of work, and we have covered that part of the integration already. The failures below happen to integrations that work — that pass QA, that process real payments, that have been live for months before anyone notices the ledger does not agree with the statement.
The STK prompt is not a payment
Start here, because it is the mistake that costs the most and is the easiest to make.
When you call the STK push endpoint and get a success response, what you have been told is that the request reached Safaricom and a prompt was sent to the customer's handset. That is all. The customer may enter their PIN. They may cancel. They may have insufficient balance. They may ignore the phone until the prompt expires.
Only the callback tells you a payment happened. If your checkout marks the order paid when the push API returns success, you will ship goods for payments that were never made.
So your order state machine needs a real pending state with an expiry, and your UI needs to say "waiting for confirmation" rather than "payment received". Customers dislike the ambiguity, and there is no way around it — the ambiguity is genuinely there.
Retries, and why the same callback arrives twice
Safaricom retries callbacks. If your endpoint is slow, errors, or times out, the same transaction comes back. If your handler had already committed the credit before that timeout, the retry credits the order a second time.
The fix is an idempotency key, and Daraja hands you one: CheckoutRequestID and MerchantRequestID are stable across retries of the same transaction. The simplest implementation that actually holds under concurrency is a unique constraint in the database, not a check-then-insert in application code:
CREATE TABLE mpesa_callbacks (
id BIGSERIAL PRIMARY KEY,
merchant_request_id TEXT NOT NULL,
checkout_request_id TEXT NOT NULL,
raw_payload JSONB NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
processed_at TIMESTAMPTZ,
CONSTRAINT uq_merchant_request UNIQUE (merchant_request_id)
);Now a duplicate insert fails, atomically, and the failure itself is the signal: this is a retry, acknowledge it and stop. A SELECT followed by an INSERT does not give you this. Two retries arriving a few milliseconds apart will both see no row and both proceed, and that window is exactly what retry behaviour is good at finding.
Slow handlers cause the retries they then have to survive
The second pattern is self-inflicted. A handler that updates the order, writes to the ledger, sends a receipt email, pushes an SMS and calls an accounting API before returning is a handler that will eventually exceed Daraja's timeout. Daraja retries. The retry lands while the first invocation is still running.
So the handler should do the minimum and get out:
POST /callbacks/mpesa
1. read body, keep the raw bytes
2. validate shape (is this the JSON we expect?)
3. INSERT the raw payload, unique on merchant_request_id
- conflict -> it is a retry, return 200
4. enqueue a job with the row id
5. return 200Everything else — crediting the order, emails, downstream calls — happens in the worker. Parse, persist, enqueue, acknowledge. The whole handler should be uninteresting.
Two operational requirements sit underneath this. The callback URL must be publicly reachable over HTTPS; M-Pesa will not call plain HTTP and will not call localhost, which is why local development needs a tunnel and why an expired certificate quietly breaks production. And the endpoint has to be up. Which brings us to the failure that idempotency does not help with at all.
Callbacks go missing
Sometimes the callback never arrives. The reasons are ordinary: your server was restarting during a deploy, the SSL certificate expired over a weekend, or there was a network problem at your cloud provider.
The customer paid. Their money left their wallet. Your system has a pending order and no confirmation. From the customer's side this is indistinguishable from being robbed, and support will hear about it.
You cannot engineer this away, so you build around it with a second and third source of truth.
Polling as a fallback. For any transaction still pending after a sensible interval, query its status rather than waiting. The STK query endpoint answers for a specific CheckoutRequestID. Run it as a scheduled sweep over pending transactions, not as a per-request poll — polling every checkout in real time is how you get rate-limited into a worse outage than the one you were avoiding.
A daily reconciliation job. Pull the M-Pesa statement for the previous day and compare it against your own records, in both directions. Transactions in the statement with no matching record are payments you owe someone. Records marked paid with nothing in the statement are worse, because that is either a bug or a double-credit. This job should produce a report even when it finds nothing, so that silence means "checked and clean" rather than "the cron died in March".
That is the shape that holds: callbacks as the primary path, polling as the fallback for the ones that go missing, and a scheduled daily comparison against the statement as the backstop for what both of them miss.
Store the raw payload before you touch it
Persist the unparsed callback JSON exactly as it arrived, before any parsing or business logic. It costs a column.
It earns that column back the first time a customer disputes a transaction, or the first time you find a bug in your parsing and need to know what you were actually sent rather than what you recorded. Parsed data reflects the code you had at the time. The raw payload does not. When a dispute has to be taken back to Safaricom, the raw record is what makes your side of it checkable.
The same reasoning applies downstream. The M-Pesa transaction reference is what ties a payment to the invoice you eventually submit, which matters if the same system is also handling KRA eTIMS invoicing.
The parts that stay hard
Some of this has no clean answer, and it is worth saying so rather than pretending otherwise.
Partial and failed states are messy. A payment can succeed at Safaricom and fail in your fulfilment logic, and now you are holding money for something you cannot deliver. Refunds are a separate integration with separate credentials.
A customer who pays, gets no confirmation, and pays again has produced two legitimate transactions that your idempotency key will not catch, because they genuinely are different transactions. Catching those needs a business rule — same amount, same phone, same order, short window — and any rule you write there will have false positives. Someone has to decide which way you would rather be wrong.
And reconciliation is only as good as your matching logic. Amounts and references usually line up. Sometimes they do not, and a person has to look.
What this changes
If you have a Daraja integration in production, four things are worth checking this week:
1. Is there a unique constraint on the callback identifier? Not a check in application code — a constraint in the database. Without it you have a double-credit waiting for a slow day.
2. How long does your callback handler take at the 99th percentile? If the answer involves an outbound API call or an email, move that work to a queue.
3. Is there a polling fallback for pending transactions? If missed callbacks are currently found by customers complaining, that is your reconciliation process, and it is not one.
4. Does a daily statement comparison run, and does anyone read the output? A reconciliation job whose report nobody opens is the same as no job at all.
None of this is exotic engineering. It is the difference between an integration that works in the demo and one that still balances after a year. We build payment and compliance integrations for the East African market, and the reconciliation layer is routinely the longer half of the job — that is the kind of build we do in Kenya.