M-Pesa STK Push failing? How to find out why
An M-Pesa STK Push can fail in two places: when your server asks Daraja to send the PIN prompt, and when Safaricom reports the result back to your callback URL. Most teams only check the first. This guide covers both, explains the Daraja error and result codes, shows how to catch a callback that never arrives, and how to tell a bug in your app from an M-Pesa outage.
The examples use Node.js (Express) with a Python version of the callback handler. The same steps apply in PHP, Java or Go.
How an STK Push works, and where it breaks
Every STK Push goes through the same steps:
- Your server gets an OAuth token from Daraja.
- Your server sends the STK Push request. Daraja replies straight away with a
CheckoutRequestID. This only means the request was accepted. No money has moved. - The customer sees the PIN prompt on their phone and enters their PIN, cancels it, or ignores it.
- Safaricom sends the final result to your
CallBackURL.
That gives two failure points:
- Request failures (step 2). Daraja rejects the request. You get an HTTP error and an error code immediately, so these are easy to see.
- Payment failures (step 4). The request was accepted but the payment didn't complete. Safaricom delivers this callback as a normal HTTP request with a
ResultCodein the body, so your logs show it as a success unless you read that code.
There's also a third case that causes the most damage: the callback never arrives. We cover it in its own section below.
Know what broke. Fix it faster.
Crash, error and network monitoring for Android, Flutter and web apps. Crash reporting is free for 10,000 events a month.
Failures at request time: Daraja error codes
When Daraja rejects an STK Push request it returns an HTTP 4xx or 5xx status with a body like this:
{ "requestId": "...", "errorCode": "404.001.03", "errorMessage": "Invalid Access Token"}The most common causes:
| Error code | Usual cause | Fix |
|---|---|---|
404.001.03 Invalid Access Token | The OAuth token expired or belongs to the other environment (sandbox vs production) | Cache the token and refresh it before it expires. Check the base URL matches the credentials. |
400.002.02 Bad Request | A field is invalid: BusinessShortCode, Timestamp, Amount or the phone number format | Build the password and timestamp as shown below. Send whole-number amounts and phone numbers as 2547XXXXXXXX. |
500.001.1001 | Often "unable to lock subscriber": the customer already has a payment prompt in progress | Don't send a second prompt while one is open. Ask the customer to finish or cancel the first one. |
| HTTP 429 or bursts of 5xx | Too many requests in a short time | Throttle on your side and retry with exponential backoff. |
Most request failures come from three things: the password, the timestamp and the token. The password is the Base64 encoding of your shortcode, passkey and timestamp joined together, and the timestamp must be in YYYYMMDDHHmmss format. Generate the timestamp in Kenyan time so it matches what Safaricom expects:
// stkpush.jsconst DARAJA = process.env.DARAJA_BASE_URL; // https://sandbox.safaricom.co.ke in testing let cachedToken = null; // { value, expiresAt } async function getToken() { if (cachedToken && Date.now() < cachedToken.expiresAt - 60_000) return cachedToken.value; const basic = Buffer.from( `${process.env.DARAJA_CONSUMER_KEY}:${process.env.DARAJA_CONSUMER_SECRET}` ).toString("base64"); const res = await fetch(`${DARAJA}/oauth/v1/generate?grant_type=client_credentials`, { headers: { Authorization: `Basic ${basic}` }, }); if (!res.ok) throw new Error(`Daraja OAuth failed: HTTP ${res.status}`); const { access_token, expires_in } = await res.json(); cachedToken = { value: access_token, expiresAt: Date.now() + Number(expires_in) * 1000 }; return access_token;} function darajaTimestamp() { // "2026-10-26 14:03:05" in Nairobi time -> "20261026140305" return new Date() .toLocaleString("sv-SE", { timeZone: "Africa/Nairobi" }) .replace(/\D/g, "");} function darajaPassword(timestamp) { const { MPESA_SHORTCODE, MPESA_PASSKEY } = process.env; return Buffer.from(`${MPESA_SHORTCODE}${MPESA_PASSKEY}${timestamp}`).toString("base64");}Then send the request and treat anything other than ResponseCode: "0" as a failure:
export async function startStkPush({ orderId, amount, phone }) { const timestamp = darajaTimestamp(); const res = await fetch(`${DARAJA}/mpesa/stkpush/v1/processrequest`, { method: "POST", headers: { Authorization: `Bearer ${await getToken()}`, "Content-Type": "application/json", }, body: JSON.stringify({ BusinessShortCode: process.env.MPESA_SHORTCODE, Password: darajaPassword(timestamp), Timestamp: timestamp, TransactionType: "CustomerPayBillOnline", Amount: Math.round(amount), PartyA: phone, PartyB: process.env.MPESA_SHORTCODE, PhoneNumber: phone, CallBackURL: process.env.MPESA_CALLBACK_URL, AccountReference: orderId, TransactionDesc: `Order ${orderId}`, }), }); const body = await res.json().catch(() => ({})); if (!res.ok || body.ResponseCode !== "0") { const err = new Error(`STK Push rejected: ${body.errorCode ?? res.status} ${body.errorMessage ?? ""}`); err.darajaCode = body.errorCode ?? String(res.status); throw err; } // Accepted. The payment is now pending until the callback arrives. await orders.markPending(orderId, { checkoutRequestId: body.CheckoutRequestID }); return body.CheckoutRequestID;}Failures in the callback: STK Push result codes
When the customer responds (or doesn't), Safaricom posts the result to your callback URL:
{ "Body": { "stkCallback": { "MerchantRequestID": "...", "CheckoutRequestID": "ws_CO_...", "ResultCode": 1032, "ResultDesc": "Request cancelled by user" } }}On success ResultCode is 0 and a CallbackMetadata object carries the amount, the M-Pesa receipt number, the transaction date and the phone number.
Most non-zero codes aren't bugs. They're the customer's choice or situation, and your app should handle them calmly:
| ResultCode | Meaning | Whose side | What to show the customer |
|---|---|---|---|
0 | Payment completed | Confirmation with the M-Pesa receipt number | |
1032 | The customer cancelled the prompt | Customer | "Payment cancelled. You can try again." |
1037 | The customer's phone couldn't be reached (off, no network, or no response) | Customer or network | "We couldn't reach your phone. Check it's on and try again." |
1 | Not enough balance | Customer | "Your M-Pesa balance is too low for this payment." |
2001 | Wrong PIN | Customer | "The PIN was incorrect. Try again." |
1001 | Another payment prompt is already in progress for this customer | Customer or your app | "Finish or cancel the other M-Pesa prompt first." Check your app isn't sending duplicate prompts. |
1019 | The prompt expired before the customer responded | Customer | "The payment request expired. Try again." |
1025, 9999 | Safaricom couldn't send the prompt | Safaricom | "M-Pesa couldn't send the prompt. Try again in a few minutes." |
A sudden rise in a customer-side code is still worth knowing about. If 1037 jumps for everyone at once, the problem is the network, not your customers.
Here's a callback handler that acknowledges Safaricom straight away, ignores duplicates, and reports the cases worth investigating to Codeskop:
// server.jsimport express from "express";import * as codeskop from "@codeskop/node"; codeskop.init({ apiKey: process.env.CODESKOP_API_KEY }); const app = express();app.use(codeskop.expressMiddleware()); const CUSTOMER_OUTCOMES = new Set([1, 1001, 1019, 1032, 1037, 2001]); app.post("/mpesa/callback", express.json(), async (req, res) => { // Acknowledge first, so Safaricom doesn't treat a slow handler as a failed delivery. res.json({ ResultCode: 0, ResultDesc: "Accepted" }); const cb = req.body?.Body?.stkCallback; if (!cb) { codeskop.captureMessage("M-Pesa callback without stkCallback"); return; } const order = await orders.findByCheckoutRequestId(cb.CheckoutRequestID); if (!order || order.status !== "pending") return; // unknown or already settled: duplicates happen if (cb.ResultCode === 0) { const items = Object.fromEntries( (cb.CallbackMetadata?.Item ?? []).map((i) => [i.Name, i.Value]) ); await orders.markPaid(order.id, { receipt: items.MpesaReceiptNumber, amount: items.Amount }); return; } await orders.markFailed(order.id, { resultCode: cb.ResultCode, resultDesc: cb.ResultDesc }); if (!CUSTOMER_OUTCOMES.has(cb.ResultCode)) { codeskop.captureException(new Error(`STK Push failed: ${cb.ResultCode} ${cb.ResultDesc}`), { tags: { provider: "mpesa", resultCode: String(cb.ResultCode) }, }); }}); app.use(codeskop.expressErrorHandler());The same handler in Python (FastAPI):
# main.pyimport osimport codeskopfrom fastapi import FastAPI, Requestfrom codeskop.integrations.asgi import CodeskopMiddleware codeskop.init(api_key=os.environ["CODESKOP_API_KEY"]) app = FastAPI()app.add_middleware(CodeskopMiddleware) CUSTOMER_OUTCOMES = {1, 1001, 1019, 1032, 1037, 2001} @app.post("/mpesa/callback")async def mpesa_callback(request: Request): body = await request.json() cb = body.get("Body", {}).get("stkCallback") if cb is None: codeskop.capture_message("M-Pesa callback without stkCallback", severity="medium") return {"ResultCode": 0, "ResultDesc": "Accepted"} order = await orders.find_by_checkout_request_id(cb["CheckoutRequestID"]) if order and order.status == "pending": code = cb["ResultCode"] if code == 0: items = {i["Name"]: i.get("Value") for i in cb.get("CallbackMetadata", {}).get("Item", [])} await orders.mark_paid(order.id, receipt=items.get("MpesaReceiptNumber")) else: await orders.mark_failed(order.id, result_code=code, result_desc=cb.get("ResultDesc")) if code not in CUSTOMER_OUTCOMES: codeskop.capture_exception( RuntimeError(f"STK Push failed: {code} {cb.get('ResultDesc')}"), tags={"provider": "mpesa", "resultCode": str(code)}, ) return {"ResultCode": 0, "ResultDesc": "Accepted"}Two details matter here. Never put the customer's phone number in an error message, since error messages are sent as written. And attach your own user ID with setUser instead, so you can see which customers were affected without storing personal data in your monitoring.
The silent failure: the M-Pesa callback never arrives
This is the failure that produces support calls. The STK Push was accepted, the customer may even have paid, but your callback URL never hears back. The order sits in "pending", the customer sees a spinner, and nobody on your team knows.
Common causes:
- Your callback URL isn't reachable from the internet: a localhost or private URL left in config, an expired tunnel in testing, a firewall, or an invalid TLS certificate.
- Your callback endpoint returned an error or timed out while Safaricom was delivering.
- A deploy changed the callback route.
- Safaricom is having an outage or a delay.
You can't rely on the callback alone. Add a reconciliation job that checks any payment still pending after a few minutes using the STK Push Query API:
// reconcile.js: run every minuteconst PENDING_LIMIT_MS = 3 * 60_000; // longer than the PIN prompt stays open export async function reconcilePendingPayments() { const stale = await orders.findPendingOlderThan(PENDING_LIMIT_MS); for (const order of stale) { const timestamp = darajaTimestamp(); const res = await fetch(`${DARAJA}/mpesa/stkpushquery/v1/query`, { method: "POST", headers: { Authorization: `Bearer ${await getToken()}`, "Content-Type": "application/json" }, body: JSON.stringify({ BusinessShortCode: process.env.MPESA_SHORTCODE, Password: darajaPassword(timestamp), Timestamp: timestamp, CheckoutRequestID: order.checkoutRequestId, }), }); const body = await res.json().catch(() => ({})); if (body.ResultCode === undefined) continue; // still processing or the query failed: try next run const code = Number(body.ResultCode); if (code === 0) await orders.markPaid(order.id, { source: "query" }); else await orders.markFailed(order.id, { resultCode: code, resultDesc: body.ResultDesc }); // We had to ask, so the callback never came. That's worth knowing about. codeskop.captureMessage("M-Pesa callback missing, settled by STK Push Query"); }}If one payment needs the query, it might be a blip. If every payment needs it, your callback URL is broken or Safaricom has a delay, and you want to know within minutes.
A timeout doesn't mean the payment failed
If your request to Daraja times out, or the callback is late, the money may already have left the customer's M-Pesa account. Telling them "Payment failed" invites them to pay twice.
- Show a neutral message: "We're confirming your payment. This can take a minute."
- Keep the order pending and let the reconciliation job settle it.
- Make the "Try again" button check the order's status first, so a customer can't start a second payment while the first is unresolved.
Is it your app or is M-Pesa down?
One failed payment tells you little. The pattern across all your customers tells you where the problem is:
| What you see | Most likely cause |
|---|---|
Request errors (400.002.02, token errors) after a deploy | Your code or config |
| Request errors on every call, with no deploy | Expired credentials, or a Daraja problem |
| Callbacks stop arriving, but STK Push Query shows payments completing | Your callback URL |
1037 and 1025 rise for all customers at once | The mobile network or M-Pesa |
| Everything fails at once, and other payment companies report problems | An M-Pesa outage |
M-Pesa outages are public. Payment providers post them on their status pages, for example Paystack's M-Pesa downtime incident and pawaPay's report of degraded Kenya M-Pesa services. If your failures start at the same time as an incident like that, tell your customers it's an M-Pesa issue and stop sending prompts until it clears.
Monitoring M-Pesa payments with Codeskop
Codeskop brings together the three signals from this guide: the calls your server makes to Daraja, the callbacks it receives, and the errors your code reports.
- Every Daraja call is recorded automatically. Once the Node.js or Python SDK is initialised, each OAuth, STK Push and query call is recorded with its status code and duration. Request and response bodies, query strings and Authorization headers are never captured, so your Daraja credentials and customers' phone numbers stay out of your monitoring.
- Alert on a rising failure rate. A Failing API alert on
/mpesa/stkpushfires when the share of failed calls passes your limit, for example 5% over 15 minutes. - Alert when callbacks stop. A fixed limit can't catch traffic that drops to zero. An Unusual change alert on your
/mpesa/callbackendpoint compares the number of callbacks with the usual level for that time of day, so it fires when they stop arriving. - Get the alert where your team already is. Codeskop sends alerts to Slack, Microsoft Teams, email or WhatsApp from your own business number, with the numbers and a link to the evidence.
- See who was affected. Each failure reported from the callback handler or the reconciliation job carries the result code and your own user ID, so support can answer "did my payment go through?" without guessing.
You can set this up on the free plan, which needs no card: create a project, add the SDK, and send a test STK Push in the Daraja sandbox.
STK Push checklist before you launch
- Cache the OAuth token and refresh it before it expires.
- Generate the timestamp in Nairobi time, in
YYYYMMDDHHmmssformat. - Send whole-number amounts and phone numbers in
2547XXXXXXXXformat. - Use a public HTTPS callback URL with a valid certificate, and test it from outside your network.
- Acknowledge callbacks immediately, then process them.
- Ignore duplicate callbacks by checking the order is still pending.
- Handle customer outcomes (
1032,1037,1,2001) with clear messages, not errors. - Run a reconciliation job with STK Push Query for payments pending more than a few minutes.
- Never show "Payment failed" after a timeout. Show "confirming" and check the status.
- Alert on the STK Push failure rate and on callbacks stopping.
Frequently asked questions
Why is my M-Pesa STK Push not showing on the phone?
The request was probably rejected before it reached the customer. Check the HTTP status and errorCode in Daraja's response. If the request was accepted, check whether the customer's phone is on and has network coverage. A 1037 result code in the callback confirms the phone couldn't be reached.
What does STK Push ResultCode 1032 mean? The customer cancelled the PIN prompt. It's a normal outcome, not an error. Show a "Payment cancelled" message and let them try again.
What does ResultCode 1037 mean?
Safaricom couldn't reach the customer's phone in time, usually because it was off, out of coverage or not answering the prompt. If 1037 rises for many customers at once, suspect a network issue.
Why am I not receiving the M-Pesa callback? Your callback URL is most likely unreachable from the internet, returning an error, or using an invalid certificate. Test it from outside your network. Until it's fixed, use STK Push Query to settle pending payments.
How long should I wait for the STK Push callback? Wait a little longer than the PIN prompt stays open on the customer's phone, then check the payment with STK Push Query. Never mark the payment as failed only because the callback is late.
How do I check an STK Push payment status?
Call the STK Push Query endpoint with your shortcode, password, timestamp and the CheckoutRequestID from the original request. It returns the same ResultCode the callback would have sent.
How can I tell if M-Pesa is down? If failures rise across all your customers at the same moment without a deploy on your side, check payment providers' status pages for M-Pesa incidents. Monitoring your failure rate over time is what lets you see this within minutes.