M-Pesa 1019 vs 1037 vs 1032: three STK failures that look identical and are not
Three customers try to pay in the same minute. One never sees a prompt. One sees it, goes to find their PIN, and comes back to a dead screen. One sees it, changes their mind, and taps cancel. If your checkout treats all three as "payment failed, please try again", you have thrown away the only information that tells you which of three completely different problems you actually have.
Daraja gives you three distinct result codes for these three cases: 1037, 1019 and 1032. They arrive in the same callback shape, they all mean no money moved, and they are constantly conflated in production code and in forum answers. But each one is telling you about a different link in the chain, and the fix for each is different. Getting them apart is the difference between guessing and knowing.
these three codes describe three stages of the same push. 1037 = the prompt never reached the phone. 1019 = it reached the phone and was shown, but the user was too slow and the session expired. 1032 = the user saw it and deliberately cancelled.
- Do not collapse them into one "failed" state. Classify on the exact
ResultCode. - Never auto-retry any of them. 1019 and 1037 get a manual Retry button; 1032 was a deliberate choice, so respect it and offer, not force, a retry.
- Log each as its own metric. The one that is rising tells you whether your problem is network (1037), flow and copy (1019), or trust and pricing (1032).
- Poll the STK Push Query API rather than waiting only on a callback, so an expired transaction does not leave you on a spinner forever.
The exact codes, verbatim
All three land inside stkCallback, and none of them carry a CallbackMetadata block, because no transaction completed:
1019: Transaction Expired
1037: MSISDN Unreachable / No Response From User
1032: Request cancelled by user
A 1019 callback looks like this:
{
"Body": {
"stkCallback": {
"MerchantRequestID": "29115-34620561-1",
"CheckoutRequestID": "ws_CO_191220191020363925",
"ResultCode": 1019,
"ResultDesc": "Transaction Expired"
}
}
}
The ResultDesc strings are not perfectly stable across the platform, so match on the numeric ResultCode, never on the description text. The number is the contract; the words are decoration.
Why they look the same and are not: three points on one timeline
An STK push is not a webhook to a phone. M-Pesa opens a session to the SIM Application Toolkit, code that runs on the SIM card, sends it a menu, and waits for the applet to return the PIN. I unpacked that delivery path in detail in the STK push that never reached the phone. What matters here is that the three codes mark three different moments along that single timeline.
1037 happens before the prompt is ever seen. The session could not deliver the menu to the handset. This is a network and device story: the phone is off, has no coverage, or is running an old SIM toolkit profile. Nothing your backend does changes it.
1019 happens at the far end of the timeline. The prompt was delivered and displayed. The user simply did not enter their PIN in time, and Safaricom closed the session after roughly one to three minutes depending on the handset. The user was present but too slow: they went to find their PIN, got distracted, or the number was long enough that they gave up.
1032 happens in the middle. The prompt was displayed and the user made an active decision to dismiss it. This is not a timeout and not a delivery problem. It is a human saying no, and it carries product information the other two do not.
The comparison table you should code against
| Code | Stage | What it tells you | What the user should see | Metric it feeds |
|---|---|---|---|---|
| 1037 | Before display | Delivery failed | "We could not reach your phone. Check it is on and has signal, then Retry." | Network / device health |
| 1019 | At expiry | Shown, user too slow | "The request timed out. Tap Retry and enter your PIN when the prompt appears." | Flow speed and copy |
| 1032 | During display | User cancelled | "You cancelled the payment. Changed your mind? Retry." | Trust and pricing |
| 1001 | N/A | Another transaction in progress | "A payment is already running. Wait a moment." | Back-off / retry health |
The fix, in steps
Step 1: Classify on the numeric code, not on success or failure
The bug in most integrations is a single branch: if result_code == 0: paid else: failed. Replace it with a map that preserves the meaning of each code.
TIMED_OUT = 1019 # shown, user too slow
UNREACHED = 1037 # never delivered
CANCELLED = 1032 # user pressed cancel
BUSY = 1001 # subscriber locked by an in-flight session
OUTCOME = {
0: "paid",
TIMED_OUT: "expired",
UNREACHED: "unreachable",
CANCELLED: "cancelled",
BUSY: "busy",
}
def classify(result_code: int) -> str:
return OUTCOME.get(result_code, "platform_error")
Anything landing in platform_error should be logged with the full callback body, because Daraja emits codes that appear in no public document. Store the raw ResultCode on the payment attempt row so you can build the three metrics later.
Step 2: Poll the query API so an expired push does not hang your UI
If you only learn the outcome from the callback, a 1019 looks the same as a lost callback: an eternal spinner. Poll the STK Push Query API with the CheckoutRequestID instead. While the prompt is still live the query returns a "being processed" style response with errorCode 500.001.1001; once the session ends it returns the terminal code, including 1019.
import base64, datetime, requests
def stk_query(checkout_request_id, shortcode, passkey, access_token):
ts = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
password = base64.b64encode(f"{shortcode}{passkey}{ts}".encode()).decode()
r = requests.post(
"https://api.safaricom.co.ke/mpesa/stkpushquery/v1/query",
json={
"BusinessShortCode": shortcode,
"Password": password,
"Timestamp": ts,
"CheckoutRequestID": checkout_request_id,
},
headers={"Authorization": f"Bearer {access_token}"},
timeout=30,
)
return r.json()
Start polling at about 25 seconds, then every 5 seconds, and stop near the session limit. Because your callback and your poll can both report the same outcome, your handler must be idempotent, which it needs to be anyway.
Step 3: Write three different messages and one Retry rule
Use the table above. The important discipline is that 1032 was a choice: do not phrase it as an error, and never auto-fire another push at someone who just cancelled. For 1019 the useful nudge is speed, because the user was willing but slow, so tell them the PIN prompt is coming and to have their PIN ready. For 1037 the nudge is the phone itself. All three get a manual, cooled-down Retry button rather than an automatic loop, because an automatic retry collides with the subscriber lock and returns 1001, which you cannot retry your way out of instantly.
Verification
You can reproduce all three deliberately, which makes this testable rather than theoretical.
- Force 1037: put a test line in airplane mode and push to it. Expect no prompt and a terminal 1037 within about 90 seconds.
- Force 1019: push to a real, reachable line, let the prompt appear, and do nothing. Expect the prompt to time out and a terminal 1019 after the session window.
- Force 1032: push to a reachable line and press cancel on the prompt. Expect 1032 almost immediately.
- Check the database after each: one attempt row, the correct stored
ResultCode, no payment row, and a status that isexpired,unreachableorcancelledrather than a genericfailed. - Check your dashboards show three separate counters incrementing, one per test.
If all three tests land in the same bucket, your classification is still collapsing them and the metrics will be useless.
What people get wrong
Treating 1019 as a declined payment. "Payment failed" is false here and it implies the user's money is in limbo. It is not: nothing was debited. Say the request expired and nothing was charged. That one sentence stops the support ticket.
Auto-retrying 1032. The user pressed cancel. Firing another prompt at them reads as your app not taking no for an answer, and on a phone that has now been locked into a session it can return 1001 anyway. Offer a Retry button and let them choose.
Lengthening your HTTP timeout to fix 1019. The push request returns in well under a second. The one to three minute window that 1019 measures is Safaricom's session timeout on the handset, entirely outside any timeout you control. Raising your client timeout does nothing.
Counting all three as one "failure rate". This is the expensive mistake. A single failure counter hides which lever to pull. If 1019 is climbing, your checkout is too slow or your copy does not warn the user a prompt is coming. If 1037 is climbing, you have a delivery problem, possibly a formatting regression in how you send the MSISDN. If 1032 is climbing, users are seeing the amount and backing out, which is a pricing or trust conversation, not an engineering one. Three metrics, three teams, three fixes.
When it is still broken
1019 dominates on every checkout. Look at when you fire the push. If you push before the user has consciously committed, on page load or on a keystroke, nobody is holding their phone and the session will expire. Fire on an explicit tap and tell the user in the same breath that a prompt is about to arrive.
1037 spikes across all users at once. That is not handsets, it is you. Check that you are still sending the phone number as 2547XXXXXXXX. A refactor that starts sending 07XXXXXXXX or +2547XXXXXXXX can push into the void.
You cannot tell 1019 from 1037 in your logs because you only stored a boolean. Backfill is impossible once the raw code is gone. Store the integer ResultCode and the raw callback body from now on; it costs a column and saves you every future diagnosis. If you have wrapped Daraja behind your own interface, this is a small change, which is one more reason to build a payments port rather than a thin Daraja wrapper.
None of these three codes will ever reach zero, because all three describe things happening on a phone you do not own. The goal is not to eliminate them. It is to know, at a glance, which one is costing you money this week.
Frequently asked questions
- What does M-Pesa ResultCode 1019 mean?
- 1019 means the STK prompt did reach the handset and was displayed, but the user did not enter their PIN before the session expired, roughly one to three minutes depending on the phone. No money moved and there is nothing to reverse. It is a user-was-too-slow outcome, not a delivery failure and not a cancellation.
- How is 1019 different from 1037?
- 1037 means the prompt never reached the handset at all: the phone was off, out of coverage, or the SIM toolkit menu was stale. 1019 means it did reach the phone and was shown, but the user ran out of time. The user experience you offer should differ: for 1019 you nudge them to be quicker next time, for 1037 you tell them to check that the phone is on and has signal.
- Is 1032 a failure I should retry automatically?
- No. 1032 means the user actively pressed cancel on the prompt. Retrying automatically ignores a deliberate choice and is a good way to annoy customers into abandoning checkout. Offer a visible Retry button instead, so the user decides whether they meant to cancel.
- Why should I log 1019, 1037 and 1032 as separate metrics?
- Because they point at three different problems. A rising 1019 rate means your timeout copy or checkout flow is too slow and users need prompting. A rising 1037 rate means a network or SIM delivery problem. A rising 1032 rate means users are changing their minds, often a pricing or trust issue. Collapse them into one 'failed' counter and you lose the ability to tell which one is hurting you.