M-Pesa callbacks fail silently for a few predictable reasons: your URL is not publicly accessible, you are using HTTP instead of HTTPS, your firewall or hosting provider is blocking Safaricom's servers, or your ngrok tunnel expired. Check each one in order, and use a tool like RequestBin to confirm Safaricom can reach your endpoint before debugging your application code.
When you initiate an STK Push, Safaricom does not return the payment result in the API response. Instead, the response just says "request accepted for processing." The actual result (payment successful, user cancelled, wrong PIN, insufficient funds) comes later via a callback to a URL you specified. If that callback never arrives, your app has no idea whether the customer paid or not.
This is the single biggest source of frustration for developers new to M-Pesa. With Stripe or PayPal, you get the payment result right there in the API response. With M-Pesa, you fire the request and then wait. And if your callback is misconfigured, you wait forever. No error message. No timeout notification. Just silence.
The result is predictable: customers pay, but the app never updates. Orders stay in "pending" limbo. Developers stare at logs that show absolutely nothing. Support tickets pile up. It is a terrible experience for everyone, and it is almost always caused by one of a handful of configuration issues that are straightforward to fix once you know what to look for.
Before debugging, it helps to understand exactly what happens between your STK Push request and the callback arriving at your server.
CallBackURL field pointing to your server (for example, https://yourdomain.com/api/mpesa/callback).CallBackURL you provided. The body contains a JSON payload with the result code, amount, receipt number, and other metadata.The critical thing to understand is that Safaricom initiates an outbound HTTP request to your server. This means your server must be publicly reachable from Safaricom's infrastructure. It is not like a webhook you subscribe to and can test locally. Safaricom's servers need to be able to open a TCP connection to your URL, complete a TLS handshake, and POST JSON data. If any step in that chain fails, the callback is silently dropped.
Safaricom does not provide detailed delivery logs for callbacks in the developer portal (at least not at the level of "we tried to connect but got a connection refused"). This makes debugging harder than it should be. You have to test from the outside in.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
After helping dozens of developers debug callback issues (and hitting every one of these ourselves), here is the list ranked by how often we see each cause.
1. Your URL is not publicly accessible. This is the number one cause, full stop. If you are running your server on localhost:3000 and your callback URL is http://localhost:3000/api/mpesa/callback, Safaricom cannot reach it. Safaricom's servers are on the internet. Your laptop is behind a NAT, a router, and probably a firewall. You need a tunnel (like ngrok) or a deployed server.
2. You are using HTTP instead of HTTPS. Safaricom requires HTTPS for callback URLs. A plain HTTP URL will be silently rejected. No error, no retry, nothing. Make sure your URL starts with https://.
3. Your SSL certificate is invalid. HTTPS with a self-signed certificate does not count. Safaricom's servers validate the SSL certificate chain. If your cert is self-signed, expired, or issued for a different domain, the TLS handshake fails and the callback is dropped. Use Let's Encrypt or a certificate from your hosting provider.
4. Your ngrok tunnel expired or changed. Free ngrok tunnels have session limits and generate a new random URL each time you restart. If you started ngrok, copied the URL into your code, and then ngrok restarted (or you closed your laptop), the URL is dead. Always check that your tunnel is active and the URL matches what you sent to Safaricom.
5. Your firewall or hosting provider is blocking the request. Some hosting platforms (especially shared hosting) block incoming POST requests from unknown IPs. Cloud firewalls (AWS security groups, GCP firewall rules) default to blocking inbound traffic. Make sure port 443 is open and Safaricom's IP ranges are not blocked.
6. Your callback endpoint has a bug and crashes. Your URL might be reachable, and the callback might be arriving, but your handler code throws an error before you log anything. Add a try-catch around your entire callback handler and log the raw request body before doing any processing.
7. Sandbox delays. In the Daraja sandbox, callbacks can be genuinely slow. We have seen delays of 30 seconds to several minutes. Before assuming something is broken, wait at least 5 minutes after an STK Push in sandbox mode.
Do not skip steps. Work through this in order. Each step eliminates a category of problems.
Step 1: Verify your URL is reachable from the internet.
Open a terminal on a different machine (or use your phone's browser) and hit your callback URL with a GET request. You should get some kind of response (even a 404 or 405 is fine at this stage, because it proves the server is reachable). If you get a connection timeout, your URL is not publicly accessible.
# From a different machine or your phone's hotspot
curl -v https://yourdomain.com/api/mpesa/callback
# If using ngrok, verify the tunnel is alive
curl -v https://abc123.ngrok-free.app/api/mpesa/callback
Step 2: Test with RequestBin (or a similar tool).
Go to requestbin.com (or webhook.site, or pipedream.com) and create a temporary endpoint. Use that URL as your CallBackURL in an STK Push request. If the callback shows up in RequestBin, the problem is on your server side, not on Safaricom's. If it does not show up, the problem is earlier in the chain (wrong URL format, sandbox issues, etc).
Step 3: Check your SSL certificate.
# Check SSL certificate validity
openssl s_client -connect yourdomain.com:443 -servername yourdomain.com
# Or use an online tool like ssllabs.com/ssltest
Look for: certificate expiry date, whether the chain is complete, and whether the certificate matches your domain name. If you see any errors in the handshake output, fix the certificate before doing anything else.
Step 4: Add bare-minimum logging to your callback handler.
// Express.js - minimal callback handler for debugging
app.post('/api/mpesa/callback', (req, res) => {
// Log FIRST, process LATER
console.log('=== CALLBACK RECEIVED ===');
console.log('Timestamp:', new Date().toISOString());
console.log('Headers:', JSON.stringify(req.headers));
console.log('Body:', JSON.stringify(req.body));
console.log('=========================');
// Always respond 200 immediately
res.status(200).json({ ResultCode: 0, ResultDesc: 'Accepted' });
});
If you see the "CALLBACK RECEIVED" log, your URL is reachable and the callback is arriving. The problem is in your processing code, not your infrastructure. If you never see the log, the callback is not reaching your server at all.
Step 5: Check Safaricom's API response for clues.
When you initiate the STK Push, log the full response. If the ResponseCode is not "0", the request itself failed and no callback will be sent. Common non-zero codes indicate authentication failures, invalid parameters, or rate limiting.
Ngrok is the standard tool for exposing your local server to the internet during M-Pesa development. But it has several gotchas that trip up developers constantly.
Basic setup that works:
# Start your Express server on port 3000
node server.js
# In a separate terminal, start ngrok
ngrok http 3000
# Ngrok outputs something like:
# Forwarding https://a1b2c3d4.ngrok-free.app -> http://localhost:3000
Use the https:// URL (not the http:// one) as your callback URL. Copy it exactly, including the subdomain. Then use this URL in your STK Push request's CallBackURL field.
Common ngrok mistakes:
Pro tip for team development: If you are on ngrok's paid plan, you can set a fixed subdomain so your callback URL never changes. This saves a lot of hassle. Alternatively, deploy a lightweight staging server (a $5/month VPS works fine) and use that for M-Pesa testing instead of ngrok entirely.
# Fixed subdomain (paid plan only)
ngrok http 3000 --domain=mpesa-dev.ngrok.app
# Now your callback URL is always:
# https://mpesa-dev.ngrok.app/api/mpesa/callback
Local development with ngrok is one thing. Production is another set of challenges entirely. Here is what to get right before real customers start paying.
Use a dedicated callback endpoint on your production domain. Your callback URL should be something like https://api.yourdomain.com/mpesa/callback or https://yourdomain.com/api/mpesa/callback. Do not use a third-party URL, a temporary tunnel, or a subdomain you might change later. Once you submit this URL in your production application, changing it requires going through Safaricom's support process.
SSL certificate checklist:
Respond immediately, process asynchronously. Your callback endpoint should return HTTP 200 within 1-2 seconds. Do not process the payment, update the database, send emails, and then respond. Respond first, then process. If Safaricom does not get a timely response, it may retry, and now you are dealing with duplicate callbacks.
// Good: respond first, process in background
app.post('/api/mpesa/callback', async (req, res) => {
// Immediately acknowledge receipt
res.status(200).json({ ResultCode: 0, ResultDesc: 'Accepted' });
// Store raw payload for safety
await db.callbackLogs.create({
payload: JSON.stringify(req.body),
receivedAt: new Date(),
});
// Process asynchronously (queue, setTimeout, or separate worker)
processCallback(req.body).catch(err => {
console.error('Callback processing failed:', err);
// You have the raw payload stored, so you can retry later
});
});
Monitor your callback endpoint. Set up uptime monitoring (UptimeRobot, Better Uptime, or similar) to alert you if your callback URL becomes unreachable. If your server goes down for 30 minutes, every payment during that window will have missing callbacks. You will need to reconcile those using the Transaction Status API.
Implement callback validation. In production, verify that callbacks are actually from Safaricom. Check that the CheckoutRequestID matches a request you initiated. Validate the amount matches what you expected. Consider IP whitelisting if Safaricom publishes their outbound IP ranges for your region.
Sometimes callbacks genuinely do not arrive. Your infrastructure is perfect, your SSL is valid, your endpoint is reachable, but the callback just never shows up. This happens. Network issues, Safaricom infrastructure hiccups, and edge cases in the callback delivery system can all cause lost callbacks.
This is why you should never rely solely on callbacks. The Transaction Status API is your safety net.
// Query transaction status when callback is missing
async function checkTransactionStatus(checkoutRequestId) {
const token = await getCachedToken();
const shortcode = process.env.MPESA_SHORTCODE;
const passkey = process.env.MPESA_PASSKEY;
const timestamp = new Date()
.toISOString()
.replace(/[-T:.Z]/g, '')
.slice(0, 14);
const password = Buffer.from(
`${shortcode}${passkey}${timestamp}`
).toString('base64');
const { data } = await axios.post(
'https://api.safaricom.co.ke/mpesa/stkpushquery/v1/query',
{
BusinessShortCode: shortcode,
Password: password,
Timestamp: timestamp,
CheckoutRequestID: checkoutRequestId,
},
{
headers: { Authorization: `Bearer ${token}` },
}
);
return data;
// ResultCode 0 = paid, 1032 = cancelled, 1037 = timeout
}
Build a background job that runs every few minutes and checks the status of any transaction that has been in "pending" state for more than 2-3 minutes. If the Transaction Status API confirms payment, process it just like you would process a callback. If it confirms failure or cancellation, update your records accordingly.
This pattern (callback as the primary notification, with Transaction Status polling as a fallback) is what production M-Pesa integrations should use. It handles the happy path efficiently (callbacks are fast when they work) while catching edge cases that would otherwise leave transactions stuck in limbo.
At McTaba Labs, this dual-path approach is part of our core payments module. Every student builds both the callback handler and the polling fallback before we consider an M-Pesa integration production-ready.
KES 9,999
Master Daraja API, STK Push, C2B, B2C, and callback handling. Build a real payment flow from scratch. 10 modules, 57 lessons.
Enrol in M-Pesa Integration