</>CodeWithKarani

M-Pesa error 1037: the STK push that never reached the phone

Karani GeoffreyKarani Geoffrey9 min read

Your checkout works. The customer taps Pay, your server calls stkpush/v1/processrequest, Daraja answers ResponseCode: "0", and the spinner starts. Ninety seconds later the callback lands and it says 1037. The customer is on the phone with support insisting no prompt ever appeared, and your app has already told them "Payment failed. Please try again."

Nothing failed. The transaction was never attempted. 1037 is the code Safaricom sends when M-Pesa pushed a menu at a handset and the handset never answered. That is a delivery problem on the phone side of the network, and it is the single most common non-error error in Daraja integrations. Treating it like a declined card is why your support inbox is full.

1037 means the STK prompt was sent but M-Pesa got no response from the handset before its session timed out. Your credentials, shortcode and payload are fine.

  • Do not auto-retry in a loop. You will collide with the subscriber lock and get 1001.
  • Show a specific message: "We could not reach your phone. Check that it is on and has signal, then tap Retry." Give a manual retry button with a 30-60 second cooldown.
  • Do not wait forever on the callback. Poll stkpushquery/v1/query with the CheckoutRequestID after about 25 seconds so you learn the outcome even if the callback is lost.
  • For repeat sufferers (very often iOS eSIM users), the SIM toolkit menu itself is stale. Safaricom self-service on *234# has a SIM and menu update option; a SIM swap at a shop fixes the stubborn cases.

The exact error: "1037: MSISDN Unreachable / No Response From User"

You will see it in the callback body, inside stkCallback:

{
  "Body": {
    "stkCallback": {
      "MerchantRequestID": "29115-34620561-1",
      "CheckoutRequestID": "ws_CO_191220191020363925",
      "ResultCode": 1037,
      "ResultDesc": "DS timeout user cannot be reached"
    }
  }
}

The description string is not stable. Depending on which leg of the platform generates it you will see either of these, and both mean exactly the same thing:

1037: MSISDN Unreachable / No Response From User
1037: DS timeout user cannot be reached

Notice what is missing: a CallbackMetadata block. On a successful push you get the amount, the receipt number, the phone number and the transaction date. On 1037 there is no metadata because there is no transaction. Nothing was debited, nothing was reserved, and there is nothing for you to reverse. If your callback handler assumes CallbackMetadata exists, this is also the code that makes it throw a KeyError at 2am.

Why this happens: the prompt is not an HTTP request

The mental model most developers carry is that STK Push is a notification, like a webhook to a phone. It is not. The prompt you see on a Safaricom handset is rendered by the SIM Application Toolkit, code that lives on the SIM card itself. M-Pesa opens a session with that applet over the mobile network, sends it a menu, and then waits for the applet to send back a response containing the PIN the user entered.

So the round trip has four hops, and only the first one is yours:

1. Your server to Daraja POST stkpush/v1/processrequest. Answered in milliseconds with ResponseCode 0. 2. Daraja to the M-Pesa core Queues the request. Credential and shortcode errors surface here, not later. 3. Core to the SIM toolkit applet A session is opened over the mobile network to code running on the SIM card. 4. Applet renders the menu and waits for a PIN Phone off, no coverage, stale SIM profile, or the user never looked at the screen. No response inside the session window, so the core returns 1037.
Every hop before the last one already succeeded. That is why 1037 tells you nothing about your code.

The session window is short, on the order of a minute. Anything that stops the applet from answering inside it produces 1037:

  • The handset is off, in airplane mode, or has no coverage. The most common cause by a wide margin, and completely outside your control.
  • The user saw the prompt and walked away. They went to find their PIN, or the prompt appeared while they were on a call and got dismissed. This is not the same as cancelling, which returns 1032.
  • The SIM's M-Pesa menu is stale. Old SIM cards carry old toolkit profiles. The push arrives and the applet does not render it. These users get 1037 every single time, on every merchant's checkout, and are convinced your app specifically is broken.
  • eSIM, especially on iOS. SIM toolkit delivery on eSIM profiles is noticeably less reliable than on physical SIMs. If your customer base skews towards iPhone users in Nairobi, you will see 1037 far more often than raw network conditions justify.

None of these are fixable from your backend. What is fixable is everything you do after the code arrives.

The fix, in four steps

Step 1: Classify result codes instead of branching on zero

Most integrations do if result_code == 0: success else: failed. That single line is the bug. The result code space has at least three distinct meanings and they need three distinct user experiences.

CodeMeaningWhat the user should see
0Paid, receipt in CallbackMetadataSuccess
1Insufficient M-Pesa balance"Not enough funds. Top up and retry."
1032User pressed cancel on the prompt"You cancelled the payment." Offer retry.
1037Prompt never answered by the handset"We could not reach your phone." Offer retry.
1001Another transaction is already in progress for this subscriber"A payment is already running. Wait a moment."
2001Wrong PIN, or an initiator credential problem"Wrong PIN." Offer retry.

1032 and 1037 are user-recoverable. 1 is user-actionable but not immediately retryable. 1001 is temporary and yours to back off from, which I wrote about separately in why retrying instantly on 1001 fails. Only a genuine platform fault deserves the words "payment failed".

RECOVERABLE = {
    1037: "unreachable",   # phone off / no signal / stale SIM menu
    1032: "cancelled",     # user pressed cancel
    1:    "insufficient",  # not enough balance
    2001: "wrong_pin",
}

def classify(result_code: int) -> str:
    if result_code == 0:
        return "paid"
    if result_code == 1001:
        return "busy"
    return RECOVERABLE.get(result_code, "platform_error")

Anything falling into platform_error should be logged with the full callback body, because Daraja emits codes that appear in no public document. I covered that in handling undocumented Daraja codes.

Step 2: Poll the query API instead of waiting on the callback

If you only learn the outcome from the callback, then a lost callback and an unreachable phone look identical to your UI: an eternal spinner. Poll instead. About 25 seconds after you initiate, start asking Daraja what happened, using the CheckoutRequestID the push returned.

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()

While the prompt is still on screen the query returns a "transaction is being processed" style response with errorCode 500.001.1001. Once the session ends it returns the terminal state, and for a phone that never answered that is:

{
  "ResponseCode": "0",
  "ResultCode": "1037",
  "ResultDesc": "DS timeout user cannot be reached"
}

Poll every 5 seconds, stop at about 90 seconds, and treat "still processing" at the end of that window the same as 1037 for display purposes while leaving the payment record open. Two sources of the same truth is fine as long as your handler is idempotent, which it must be anyway because M-Pesa callbacks fire twice.

Step 3: Write the message the user can act on

"Payment failed" is the worst possible copy here, because it is false and it implies the user's money is somewhere uncertain. Replace it with something that names the actual condition and gives one action:

We could not reach 0712 345 678. Make sure the phone is on, unlocked and has network, then tap Retry. Nothing has been charged.

"Nothing has been charged" is the sentence that stops the support ticket. Include it.

Step 4: Make retry manual, cooled down, and idempotent

Put a Retry button in the UI, disabled for 30 seconds after the failure. Each press creates a new CheckoutRequestID against the same internal order, so your ledger has one order with three attempts, not three orders. Key the attempt table on CheckoutRequestID with a unique index, so a duplicated callback cannot create a second payment row.

attempt initiated CheckoutRequestID stored awaiting outcome 0 paid receipt number in CallbackMetadata, order closed 1037 unreachable nothing charged, retry after a 30 second cooldown 1032 cancelled user pressed cancel, retry immediately allowed 1001 subscriber busy back off, do not create a new attempt yet Retry creates a new attempt under the same order id.
One order, many attempts. If your schema cannot express that, 1037 will eventually create duplicate orders for you.

Verification: prove it before you ship

You can reproduce 1037 on demand, which is rare and useful. Take a test line, put it in airplane mode, and push to it.

  1. Initiate an STK push to the number with the handset in airplane mode. Expect ResponseCode: "0" immediately, as always.
  2. Start your query poll. For the first 30-60 seconds expect an errorCode of 500.001.1001 with a "transaction is being processed" message.
  3. Within about 90 seconds expect "ResultCode": "1037" from the query, the callback, or both.
  4. Check the UI. It must say the phone could not be reached, must say nothing was charged, and must show a Retry button that is disabled and then enables.
  5. Check your database. Exactly one order row, exactly one attempt row, status not "failed" but something like "unreachable", and no payment row at all.
  6. Take the handset out of airplane mode and press Retry. A prompt should appear, and on completion the same order should close as paid with one payment row.

If step 6 creates a second order, stop and fix your data model before going anywhere near production traffic.

What people get wrong

Automatic retry loops. The most popular fix on forums is to catch 1037 and immediately fire another push, sometimes three times. This is actively harmful. M-Pesa locks a subscriber while a session is open, so your second push often returns 1001 and your third one too, and the user, whose phone has now come back into coverage, gets a queue of prompts and can genuinely end up paying twice. Retry belongs under a human thumb.

Increasing your HTTP timeout. The push request itself returns in well under a second. No timeout you control has any influence on whether the handset answers.

Blaming credentials. 1037 arrives in a callback, which means Daraja already authenticated you, already validated your shortcode and already accepted the push. If your credentials were wrong you would have been stopped much earlier with a completely different shape of error, which I unpick in 401.003.01 is not 404.001.03.

Flagging the customer as suspicious. I have seen risk rules that count "failed payments" and lock accounts. 1037 is not a failed payment. Counting it will quietly ban your most rural and your most iPhone-owning customers, which is a strange pair of segments to lose at the same time.

Hiding the phone number. A surprising share of 1037s are a mistyped number that happens to be a valid, registered, currently-off line. Echoing the masked number back in the error message lets the user catch their own typo.

When it is still broken

If every push to one number returns 1037, on every attempt, for days, the SIM menu is the suspect, not the network. Ask the user whether M-Pesa appears in their SIM toolkit menu at all. The community reference for this code recommends the Safaricom self-service menu on *234# to update the SIM menu; when that does not help, a SIM swap at a Safaricom shop does. It is a genuinely useful support script and worth putting in your help centre.

If 1037 spikes across all users at once, it is not handsets. Check the Daraja status and check whether your PhoneNumber formatting changed. The API expects the MSISDN in 2547XXXXXXXX form; a refactor that starts sending 07XXXXXXXX or +2547XXXXXXXX can push to nowhere in interesting ways. If the prompt is failing to send at all rather than failing to be answered, you are more likely looking at 1025 or 9999.

If your 1037 rate is above roughly one in five, look at when your pushes fire. Pushing before the user has consciously committed, for example on page load or on a keystroke, guarantees timeouts because nobody is holding their phone yet. Fire the push on an explicit tap, and tell the user in the same breath that a prompt is coming.

If it only affects iOS users, you have found the eSIM pattern. There is no server-side fix. What helps is a longer visible countdown in the UI and a second channel: offer a paybill number and account reference as a fallback so those users can pay from the M-Pesa app themselves, and reconcile it against the same order. If you already have a payments port rather than a Daraja wrapper, adding that fallback is a small change instead of a rewrite.

1037 will never go to zero. It is the network telling you about a phone you do not own. The goal is not to eliminate it, it is to make it cost you one calm retry instead of one support ticket.

Frequently asked questions

What does M-Pesa error 1037 actually mean?
It means M-Pesa sent the STK prompt but the handset never sent a response back before the session timed out, so the transaction was never attempted. The ResultDesc appears as either 'MSISDN Unreachable / No Response From User' or 'DS timeout user cannot be reached' depending on which part of the platform generated it. No money moves and there is nothing to reverse.
Should I automatically retry an STK push after a 1037?
No. M-Pesa locks a subscriber while a session is open, so an immediate retry usually returns 1001 (transaction in progress), and if the phone comes back into coverage the user can receive several queued prompts and pay more than once. Show a Retry button with a 30 to 60 second cooldown and let the user press it.
Why do iPhone users get M-Pesa 1037 more often?
The STK prompt is rendered by code on the SIM card itself, and delivery to eSIM profiles is less reliable than to physical SIMs, with iOS eSIMs the most commonly reported case. There is no server-side fix. Offer those users a paybill number and account reference as a fallback so they can pay from the M-Pesa app while you reconcile it to the same order.
How do I detect 1037 without waiting for the callback?
Poll the STK Push Query API at mpesa/stkpushquery/v1/query with the CheckoutRequestID from the original push, starting about 25 seconds in and every 5 seconds after that. While the prompt is still live you get a 500.001.1001 'being processed' response; once the session ends you get the terminal ResultCode, including 1037.
#M-Pesa#Daraja#STK Push#Payments#Error Handling
Keep reading

Related articles