M-Pesa Callback URLs: Why Yours Is Not Firing
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.
Why Callbacks Are the Heart of M-Pesa Integration
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.
How M-Pesa Callbacks Actually Work Under the Hood
Before debugging, it helps to understand exactly what happens between your STK Push request and the callback arriving at your server.
- Your server sends the STK Push request to Safaricom's API, including a
CallBackURLfield pointing to your server (for example,https://yourdomain.com/api/mpesa/callback). - Safaricom validates your request and, if it passes, sends a push prompt to the customer's phone.
- The customer either enters their PIN, cancels, or does nothing (timeout after about 60 seconds).
- Once the transaction resolves, Safaricom's servers make a POST request to the
CallBackURLyou provided. The body contains a JSON payload with the result code, amount, receipt number, and other metadata. - Your server must respond with HTTP 200 within a few seconds. If Safaricom does not get a timely 200 response, it may retry the callback (which can cause duplicate processing if you are not careful).
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.
The Seven Most Common Causes (In Order of Likelihood)
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.
Step-by-Step Debugging Process
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.
Getting Ngrok Right for Local Development
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:
- Using the HTTP URL instead of HTTPS. Ngrok gives you both. Always use the HTTPS one for M-Pesa.
- Not restarting after the tunnel changes. Free ngrok generates a new URL every time you restart. If you hardcoded the URL in your .env file and then restarted ngrok, the old URL is dead. Update your .env and restart your server.
- Ngrok's browser interstitial page. On the free plan, ngrok shows a "Visit Site" interstitial to browser visitors. This does not affect API callbacks (Safaricom sends a POST, not a browser request), but it can confuse you when testing manually in the browser. If you are on a paid plan, you can disable this.
- Session timeout. Free ngrok sessions expire after a few hours. If your tunnel dies while you are waiting for a callback, it will never arrive. Keep an eye on the ngrok terminal for disconnection messages.
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
Production Callback Configuration
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:
- Certificate must be issued by a trusted CA (Let's Encrypt is fine and free)
- Certificate must not be expired (set up auto-renewal with certbot)
- Certificate must match your domain (including subdomain)
- Full certificate chain must be served (intermediate certificates included)
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.
When Nothing Works: The Transaction Status Query Fallback
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.
Key Takeaways
- ✓Callbacks fail silently. Safaricom does not tell you why your callback was not delivered, so you have to systematically eliminate causes.
- ✓HTTPS with a valid SSL certificate is mandatory. Self-signed certificates, expired certs, and plain HTTP will all cause silent failures.
- ✓Use RequestBin or a similar tool to verify Safaricom can reach your endpoint before debugging your own code.
- ✓Ngrok free tier tunnels expire and change URLs. If you are developing locally, check that your tunnel is still active and the URL in your STK Push request matches.
- ✓Always respond with HTTP 200 immediately. If your callback endpoint takes too long to respond, Safaricom treats it as failed and may retry, causing duplicates.
Frequently Asked Questions
- Why does M-Pesa use callbacks instead of returning the result directly?
- M-Pesa transactions involve the customer's phone. After the STK Push prompt is sent, the customer has about 60 seconds to enter their PIN or cancel. Since this takes an unpredictable amount of time, Safaricom cannot hold your API request open. Instead, they accept the request immediately and notify you of the result later via a callback. This is similar to how webhooks work in Stripe or PayPal.
- Can I use a free ngrok plan for M-Pesa development?
- Yes, but with limitations. Free ngrok tunnels generate a random URL each session, expire after a few hours, and show a browser interstitial page. This works for quick testing, but you will need to update your callback URL every time the tunnel restarts. For serious development, consider ngrok's paid plan (fixed subdomains) or deploy a cheap staging server.
- How long should I wait for a callback before assuming it failed?
- In sandbox, callbacks can take up to 5 minutes. In production, they usually arrive within 5-30 seconds. If you have not received a callback after 3 minutes in production, query the Transaction Status API to check the result. Do not wait longer than that to update your user, because the customer is likely staring at a loading screen.
- Does Safaricom retry failed callbacks?
- Safaricom may retry callbacks if it does not receive a timely HTTP 200 response from your server. The retry behavior is not well documented, so do not rely on it. Implement your own fallback using the Transaction Status API. And make sure your callback handler is idempotent (can safely process the same callback twice) in case Safaricom does retry.
- Can I test callbacks without initiating a real STK Push?
- Not through Safaricom's systems directly. But you can (and should) test your callback handler by sending mock POST requests with the expected JSON structure using curl or Postman. This lets you verify your handler code works correctly before involving the actual Daraja API. The callback JSON format is documented on the Daraja portal.
- My callback works in sandbox but not in production. What changed?
- The most common cause is different SSL requirements. Sandbox may be more lenient with certificate validation than production. Verify your production SSL certificate is valid, not self-signed, not expired, and includes the full certificate chain. Also confirm you updated all URLs from sandbox.safaricom.co.ke to api.safaricom.co.ke, including the callback URL registration.
Ready to build real-world apps?
Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.
Apply to the McTaba Marathon