Tutorial 2 of 9
Testing callbacks on localhost
A push succeeds. Nothing else happens.
That is what a callback problem looks like from your side, and it is the most common way a first M-Pesa integration stalls. Safaricom accepted the request, the prompt reached the phone, the customer paid — and your application never heard about it.
The cause is nearly always the same: Safaricom calls your callback URL from its
own servers, and localhost is not a place it can call.
Start without a tunnel
Section titled “Start without a tunnel”The instinct is to reach for ngrok first. Don’t — most callback work needs no network at all.
Your callback route is an ordinary Laravel route, and the payload is ordinary JSON. Post it yourself:
curl -X POST http://localhost:8000/daraja/stk \ -H 'Content-Type: application/json' \ -d '{ "Body": { "stkCallback": { "MerchantRequestID": "29115-34620561-1", "CheckoutRequestID": "ws_CO_191220191020363925", "ResultCode": 0, "ResultDesc": "The service request is processed successfully.", "CallbackMetadata": { "Item": [ { "Name": "Amount", "Value": 1500.0 }, { "Name": "MpesaReceiptNumber", "Value": "NLJ7RT61SV" }, { "Name": "PhoneNumber", "Value": 254712345678 } ] } } } }'That exercises your listener, your database writes and your queue, in a loop that takes a second rather than a minute. Do it as a test and it exercises them on every commit as well:
it('marks the order paid', function () { Event::fake();
$this->postJson('/daraja/stk', $successfulStkPayload)->assertOk();
Event::assertDispatched(StkCallbackReceived::class);});The failure payload is the one worth keeping beside it, because it has no
CallbackMetadata key at all:
{ "Body": { "stkCallback": { "MerchantRequestID": "29115-34620561-1", "CheckoutRequestID": "ws_CO_191220191020363925", "ResultCode": 1032, "ResultDesc": "Request cancelled by user" } }}Handlers that reach straight into CallbackMetadata work perfectly against
every successful payment and throw on the first cancellation. Which arrives at
2am, from a real customer, whose money is now somewhere between Safaricom and
your order table.
When you do need Safaricom
Section titled “When you do need Safaricom”Posting payloads proves your application. It does not prove that Safaricom can reach you, that your URL is registered correctly, or that the payload shape you invented matches the one Safaricom actually sends.
For that you need a public URL. Three ways, in ascending order of reliability.
A tunnel
Section titled “A tunnel”ngrok http 8000Take the HTTPS address it prints and use it as your callback URL:
DARAJA_STK_CALLBACK_URL=https://a1b2c3d4.ngrok-free.app/daraja/stkLaravel also needs to know it is being served from that host, or generated URLs and CSRF checks disagree with reality:
APP_URL=https://a1b2c3d4.ngrok-free.appexpose and cloudflared do the same job. cloudflared is the one to reach
for if a corporate network blocks the others.
Two more things that catch people out with tunnels:
- The URL changes every restart on free plans. Register a URL, restart the tunnel, and callbacks go to an address that no longer exists.
- Public tunnels are frequently blocked, especially in production. Sandbox is usually tolerant; production is not. A tunnel is a development tool, never a deployment.
A staging deployment
Section titled “A staging deployment”The version that always works: a real host with a real certificate, running the same code. Any small VPS or platform will do — the requirement is only that it is reachable and has HTTPS.
This is also the only way to test the case where your application is down when the callback arrives, which is a real production scenario and one nobody tests until it has already happened.
Sandbox still needs a public URL
Section titled “Sandbox still needs a public URL”Sandbox permits plain HTTP, which is the only concession it makes. Safaricom
still has to reach your host from the internet, so a .test domain or
127.0.0.1 fails in sandbox exactly as it fails in production.
Registering the URL
Section titled “Registering the URL”For STK, the callback URL is sent with each push, so setting the config value is enough:
DARAJA_STK_CALLBACK_URL=https://your-public-url/daraja/stkC2B is different — those URLs are registered against your short code, once:
php artisan daraja:register-urlsThe one people miss is that this registration is per short code and effectively permanent. Register a tunnel URL that later disappears and every C2B confirmation goes nowhere until you register again.
Seeing what arrived
Section titled “Seeing what arrived”Turn on the package’s logging while you are working:
DARAJA_LOGGING_ENABLED=trueDARAJA_LOG_CHANNEL=stackThen watch it live:
php artisan pailIf your tunnel has a request inspector — ngrok’s is at
http://127.0.0.1:4040 — use it. It shows you what Safaricom actually sent,
including the requests your application rejected before any of your code ran.
Nothing arrived. Now what?
Section titled “Nothing arrived. Now what?”Work down this list. It is ordered by how often each one turns out to be the answer.
- Is the URL public? Open it from your phone on mobile data, not from the machine running the tunnel.
- Does the URL contain a forbidden word?
mpesa,safaricom,exe,exec,cmd,sql,query. - Is it the URL you actually registered? Tunnel restarts change it. For
C2B, re-run
daraja:register-urls. - Is
DARAJA_ROUTES_ENABLEDtrue, and doesphp artisan route:listshow the endpoint? - Is something rejecting the request before your code? A CSRF check on a POST route, an IP allow-list, a maintenance mode, a firewall.
- Did the callback arrive and your listener throw? Check the log. This one looks identical to “nothing arrived” from the database’s point of view, and it is the worst case, because the money moved.
- Ask Safaricom what it thinks happened, with
Daraja::stk()->query($checkoutRequestId). If that says paid and you have no record, the callback is the broken part rather than the payment.
A note on verifying callbacks
Section titled “A note on verifying callbacks”There is no signature on a Daraja callback and no shared secret. The only check available is the source address:
DARAJA_ALLOWED_IPS=196.201.214.0/24,196.201.212.0/24use Starnerz\LaravelDaraja\Http\Middleware\VerifySafaricomIp;
Route::post('/daraja/stk', StkController::class) ->middleware(VerifySafaricomIp::class);The list ships empty, which permits everything, and Safaricom issues its ranges to partners rather than publishing them. Ask your account manager.
Even with it in place, treat a callback as a claim rather than proof. Before
releasing anything expensive, confirm independently — with
Daraja::stk()->query(), or with
Transaction Status.
Once callbacks are arriving, the remaining question is what happens on the day one does not.
Reconciling payments is how you find it before your customer does.
Laravel Daraja is free and open source, maintained by eazysoft.
An independent open-source project, not affiliated with, endorsed by, or sponsored by Safaricom PLC or Laravel LLC. M-Pesa and Daraja are trademarks of Safaricom PLC; Laravel is a trademark of Laravel LLC. Used here only to say what this package works with.