M-Pesa C2B Sandbox Callbacks Only Fire Half the Time
It is 11pm and you have registered your C2B URLs four times. The register call returns
ResponseCode: "0". The simulate call returns success. Your phone shows the money left the
test wallet. And your confirmation endpoint has been sitting there, quiet, for twenty minutes.
So you do what everybody does. You restart ngrok. You add a trailing slash. You try
/confirmation instead of /c2b/confirmation. You disable CSRF. You put a
console.log at the top of the handler and redeploy. Then, on the seventh simulate, a payload
lands, and you cannot tell which of the seven things you changed fixed it. Nothing fixed it. It was
always going to land on roughly four out of ten attempts.
The Daraja C2B sandbox does not deliver callbacks reliably. That is not a controversial claim in the Nairobi developer community, it is just one that nobody writes down, so every new integrator spends two days debugging infrastructure that was working the entire time.
Safaricom's C2B sandbox drops a large share of validation and confirmation callbacks. Community experience puts delivery at roughly 40 percent of simulate calls, and there is nothing on your side that fixes it.
- Use the sandbox to confirm your register URL call returns
ResponseCode: "0", and nothing more. - Deploy the callback handler to a real HTTPS host (a small VPS, Railway, Render, Fly) and register that URL.
- Validate the handler locally by POSTing a saved payload to it with
curl, not by waiting on Safaricom. - Once deployed, test with real KES 1 to KES 10 transactions on a live paybill or till.
- Never rely on the callback as the only source of truth. Reconcile from the M-Pesa statement.
What you actually see: no error, no callback
There is no error string for this one, which is exactly why it eats days. Every response you get back says everything worked.
curl -s -X POST https://sandbox.safaricom.co.ke/mpesa/c2b/v1/registerurl \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"ShortCode": 600XXX,
"ResponseType": "Completed",
"ConfirmationURL": "https://pay.example.com/mpesa/c2b/confirmation",
"ValidationURL": "https://pay.example.com/mpesa/c2b/validation"
}'
{
"ResponseCode": "0",
"ResponseDescription": "success"
}
Then you simulate a payment, get another success response, and your access log stays empty. No 4xx. No TLS handshake failure. No entry at all, because Safaricom never made the request.
Why the sandbox behaves like this
Three separate things are happening, and people conflate them.
1. The sandbox callback dispatcher is genuinely flaky. Sandbox is a shared test
environment that Safaricom does not operate to production standards, and the outbound callback path is
the weakest part of it. A simulate call that returns ResponseCode: "0" only means the
simulate request was accepted, not that a confirmation will ever be dispatched. There is no retry,
no dead-letter queue, and no way to ask for a redelivery.
2. Your callback URL may be structurally unacceptable. Daraja only calls public
HTTPS URLs on the standard port. http://, localhost, an IP with a self-signed
certificate, or a URL with a query string will silently never be called. This is a real cause of zero
percent delivery, and it is different from the flakiness above. If you get nothing at all, ever,
suspect the URL. If you get some, you are in flaky-sandbox territory.
3. ngrok free tier is a trap. A free ngrok tunnel gives you a hostname that rotates every restart, serves an interstitial warning page to non-browser clients, and lives on a shared domain that gets abused constantly. Safaricom is known to block traffic to free tunnelling hosts on the production side, so an ngrok URL that half-works in sandbox will not carry you to go-live anyway. You would have to redo the whole callback registration on a real domain regardless.
The fix, step by step
Step 1: Prove the callback URL is structurally valid
Before blaming anything, confirm the URL Safaricom would call is reachable from the public internet over HTTPS with a valid certificate, with no query string and no redirect.
curl -i -X POST https://pay.example.com/mpesa/c2b/confirmation \
-H "Content-Type: application/json" \
-d '{"TransID":"TEST123","TransAmount":"10.00","MSISDN":"2547XXXXXXXX","BillRefNumber":"INV-001"}'
Expected: HTTP/2 200 and a JSON body. If you see a 301 or 302, fix it. Daraja will not
follow your redirect from example.com to www.example.com. If you see a 403 from a
WAF or a CSRF middleware, exempt the callback route now, because that failure is invisible later.
Step 2: Log the raw body before you parse it
Half the "callback never arrived" reports I have looked at were callbacks that arrived and blew up in a JSON schema validator, returning a 500 that nobody read. Write the raw bytes to a log line before any parsing happens.
@app.post("/mpesa/c2b/confirmation")
async def c2b_confirmation(request: Request):
raw = await request.body()
log.info("c2b_confirmation raw=%s", raw.decode("utf-8", "replace"))
try:
payload = json.loads(raw)
handle_confirmation(payload) # must be idempotent on TransID
except Exception:
log.exception("c2b_confirmation handler failed")
# Always acknowledge. Never let a handler bug cause a retry storm.
return {"ResultCode": 0, "ResultDesc": "Success"}
Note the try wrapping the business logic and the unconditional 200. Acknowledge receipt,
then process. If your processing throws, you still have the raw line in the log and can replay it. This is
the same discipline described in handling
duplicate and missing M-Pesa callbacks.
Step 3: Replay a saved payload locally instead of waiting
Once you have one real confirmation body in your logs, save it as a fixture. Now your local test loop is
a curl against localhost that runs in 50ms, not a simulate call that may never
come back. This single change removes the sandbox from your inner development loop entirely.
curl -s -X POST http://localhost:8000/mpesa/c2b/confirmation \
-H "Content-Type: application/json" \
--data @fixtures/c2b_confirmation.json | jq .
Step 4: Deploy, then register the deployed URL
Put the service on a real host with a real certificate. A 1 GB VPS in any region costs less per month than the time you are burning, and you need it before go-live anyway. Register the deployed URLs against sandbox first, then against production when your shortcode is issued.
The validation URL is optional in practice. If you register one, every payment waits on your response, and a slow or failing validation endpoint will start rejecting real customer payments. Unless you have a genuine business reason to reject payments in flight, register only the confirmation URL.
Step 5: Test with real money, in small amounts
Once your paybill or till is live, KES 10 through the real Safaricom rails exercises the entire path in a way sandbox never will: real MSISDN formatting, real name fields, real timing, real duplicate behaviour. Ten transactions at KES 10 costs you a hundred shillings and is worth more than a week of simulate calls.
Verification
You know it is working when all three of these are true on a deployed environment:
# 1. The register call was accepted
# {"ResponseCode":"0","ResponseDescription":"success"}
# 2. A real payment produced a log line within a few seconds
grep c2b_confirmation /var/log/app/app.log | tail -1
# 3. The same TransID processed twice changes nothing the second time
curl -s -X POST https://pay.example.com/mpesa/c2b/confirmation \
-H "Content-Type: application/json" --data @fixtures/c2b_confirmation.json
curl -s -X POST https://pay.example.com/mpesa/c2b/confirmation \
-H "Content-Type: application/json" --data @fixtures/c2b_confirmation.json
After the double POST, your payments table must contain exactly one row for that TransID.
If it contains two, the callback flakiness is the smaller of your problems.
What people get wrong
| Common advice | Why it does not help |
|---|---|
| "Re-register your URLs, it needs a fresh registration" | Registration is idempotent per shortcode. Re-registering an identical URL changes nothing about dispatch reliability. |
| "Use ngrok, it works fine" | It works in sandbox sometimes and does not survive to production. You are testing a path you will delete. |
| "Return 200 with an empty body, Safaricom does not read it" | Return the JSON acknowledgement. It costs nothing and it is what the docs specify. |
| "Open a ticket with apisupport" | Worth doing for production issues. For sandbox callback delivery you will wait longer than it takes to deploy. |
| "Poll the transaction status API instead" | Transaction Status is per-transaction and rate limited. It is a reconciliation tool, not a substitute for a callback. |
The most expensive one is the belief that a green sandbox means a working integration. It does not. The sandbox proves your credentials and your request shapes are correct. It proves nothing about your callback handling, because the sandbox is the least reliable part of the callback path.
When it is still broken after deploying
- Check the web server access log, not the application log. If nginx logged a request your app never saw, the problem is routing or a body-size limit, not Daraja. My nginx reverse proxy notes cover the usual suspects.
- Check for a rate limiter or bot filter. Cloudflare, a WAF rule, or fail2ban will drop a repeated unauthenticated POST from an unfamiliar network. Allowlist the callback path.
- Confirm the shortcode you registered against is the one being paid. Sandbox hands out more than one test shortcode and they are easy to mix up.
- Build the reconciliation job now. Pull the M-Pesa statement daily, match on
TransID, and insert anything the callback missed. See M-Pesa statement reconciliation for the double-entry approach. Production callbacks are more reliable than sandbox ones, but they are not 100 percent, and money that arrived without a matching record is a support ticket you will get eventually.
The broader lesson: any integration whose only path to truth is an inbound webhook is one dropped packet away from a wrong balance. The callback is the fast path. Reconciliation is the correct path. Build both, and the sandbox stops mattering.
Frequently asked questions
- Why does my M-Pesa C2B confirmation URL never get called in sandbox?
- Safaricom's C2B sandbox dispatches callbacks unreliably, and community experience puts delivery at roughly 40 percent of simulate calls. There is no retry and no way to request redelivery, so a missing callback usually means the sandbox dropped it rather than that your endpoint is broken. Confirm your URL is public HTTPS on the standard port with no redirect, then move testing to a deployed environment.
- Can I use ngrok for M-Pesa callback URLs?
- Only for the first hour of local exploration. Free ngrok hostnames rotate on every restart, serve an interstitial page to non-browser clients, and sit on shared domains that Safaricom is known to block on the production side. Since you have to re-register a real domain before go-live anyway, deploying to a small VPS or a platform like Railway earlier saves the whole detour.
- Should I register a validation URL as well as a confirmation URL?
- Usually not. A registered validation URL puts your service in the critical path of every incoming payment, so if it is slow or returns an error, real customer payments get rejected. Register only the confirmation URL unless you have a genuine business rule that must reject payments while they are still in flight.
- How do I test an M-Pesa C2B integration without waiting on sandbox callbacks?
- Capture one real confirmation payload from your logs, save it as a fixture file, and POST it at your handler with curl. That gives you a 50 millisecond test loop that exercises parsing, idempotency and database writes without involving Safaricom at all. Use sandbox only to confirm that authentication and the register URL call succeed.