M-Pesa 1001 Transaction In Progress: Why Retrying Instantly Fails
Saturday afternoon, a customer is at checkout, and the prompt is not showing on her phone. She taps Pay again. Your backend, being helpful, retries three times with a 500ms gap. Your logs now read 1001, 1001, 1001 inside four seconds, the UI says the payment failed, and about eight seconds later the original prompt finally lands on her handset and she pays anyway.
Now you have a customer who has paid for an order your system marked as failed, and a support conversation on a Saturday.
Error 1001 is not a transient network blip. It is a lock. Safaricom is telling you that this exact subscriber already has a session open and cannot be given another one. Retrying immediately does not race past the lock, it just asks the same question faster, and the most common thing holding that lock is the STK push you sent five seconds earlier.
stop retrying. On 1001, hold the checkout in a "blocked" state, do not mark it failed, and do not send another push to that MSISDN for at least 60 to 90 seconds. Tell the customer in plain words to finish or cancel the M-Pesa menu currently open on their phone, then offer a manual retry with a visible countdown. Because the earlier push may still be delivered and paid, treat the callback (or an STK Push Query) as the only source of truth about whether money moved.
The exact error
You will meet it in two places. Sometimes Daraja rejects the STK push request synchronously, and sometimes the rejection arrives on your callback URL:
{
"Body": {
"stkCallback": {
"MerchantRequestID": "29115-34620561-1",
"CheckoutRequestID": "ws_CO_191220191020363925",
"ResultCode": 1001,
"ResultDesc": "Unable to lock subscriber, a transaction is already in process for the current subscriber"
}
}
}
Some references label the same code simply Transaction In Progress. Both describe the same condition, and that is the first practical lesson: branch on ResultCode, never on ResultDesc. The numeric code has been stable for years; the human-readable text has not, and a string comparison against it is a bug waiting for a Safaricom deployment.
What the lock actually is
An STK push is not a push notification. Safaricom delivers it to the SIM as a session, and a SIM can hold one of those at a time. While a session is open for that MSISDN, nothing else can claim it. So 1001 fires whenever the customer is in the middle of:
- your own earlier STK prompt, still on screen or still in flight
- another merchant's STK prompt they never dismissed
- a USSD menu they dialled themselves, including the M-Pesa menu, airtime top-up, or a balance check
- a Fuliza or overdraft confirmation
- a session that has been dismissed from the screen but has not timed out on the network side yet
That last one is the cruel case, because the customer will tell you, honestly, that nothing is open on their phone. Dismissing the prompt does not always release the lock immediately. It clears on its own, typically inside a minute or two.
Where 1001 sits among the other result codes
The single most useful thing you can do is stop treating "the request was not accepted" and "the payment failed" as the same event. They need different UI, different retry policy and different accounting.
| ResultCode | Meaning | Did money move? | Retry policy |
|---|---|---|---|
| 0 | Success | Yes | Never retry. Reconcile and fulfil. |
| 1 | Insufficient balance | No | Do not auto-retry. Tell them to top up. |
| 1001 | Subscriber already has a session | Possibly, on the earlier attempt | Wait 60 to 90 seconds, then allow one manual retry. |
| 1019 | Transaction expired | No | Safe to start a fresh checkout. |
| 1032 | Cancelled by the user | No | Immediate retry is fine. They pressed cancel. |
| 1037 | No response from the handset | No | Retry after a pause. Often network or a phone that is off. |
Only 1001 carries genuine ambiguity about whether money moved, because it implies a prior session existed. That is why it must never be rendered as a red "payment failed" toast.
The fix
Step 1: Make a second push for the same MSISDN impossible
Guard before you call Daraja, not after. One in-flight STK push per phone number, enforced server-side, because the front end will always find a way to double-submit.
STK_COOLDOWN_SECONDS = 90
def initiate_stk(checkout):
key = f"stk:lock:{checkout.msisdn}"
# Atomic: only the first caller in the window gets through.
if not cache.add(key, checkout.id, timeout=STK_COOLDOWN_SECONDS):
raise SubscriberBusy(
retry_after=cache.ttl(key),
existing_checkout=cache.get(key),
)
response = daraja.stk_push(checkout)
if response.result_code == 1001:
# Keep the cooldown in place. Do not clear the key.
checkout.mark_blocked(retry_after=STK_COOLDOWN_SECONDS)
return checkout
checkout.mark_pending(response.checkout_request_id)
return checkout
Note what the code does not do on 1001: it does not clear the cooldown key, and it does not call mark_failed. The window stays shut precisely because a session exists.
Step 2: Give the front end a retry_after and show a countdown
Return the remaining seconds in your API response and render it. A button that says Retry in 47s stops the customer hammering it, which stops you generating more 1001s, which is most of the problem solved.
Step 3: Write the message the customer can actually act on
"Payment failed, please try again" is the worst possible copy here, because trying again is exactly what will fail. Something closer to:
Your phone already has an M-Pesa or USSD menu open. Please finish it or press Cancel on your handset, then tap Retry in about a minute.
In Kenya this instruction lands immediately, because everyone recognises the situation of a half-open menu on their phone. It is also honest, which "failed" is not.
Step 4: Never mark the checkout paid or failed from the push response
After the cooldown, before you push again, ask what happened to the previous attempt. If you have a CheckoutRequestID from an earlier push, query it:
curl -X POST \
https://api.safaricom.co.ke/mpesa/stkpushquery/v1/query \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"BusinessShortCode": "174379",
"Password": "'"$PASSWORD"'",
"Timestamp": "20260724120401",
"CheckoutRequestID": "ws_CO_191220191020363925"
}'
If that earlier attempt succeeded, you must not push again, or you charge the customer twice. This is the same discipline as handling duplicate confirmations, which I covered in detail in why your M-Pesa callback will fire twice.
Verification
You can reproduce this deliberately, which is the only way to know your handling works:
- On a test handset, open any USSD menu and leave it on screen.
- Trigger a checkout for that number from your app.
- Expect: your API returns a blocked state with a
retry_after, the UI shows the countdown and the "finish or cancel the menu on your phone" message, and the pay button is not clickable. - Check your logs. There must be exactly one call to Daraja, not four.
- Close the menu, wait out the countdown, tap Retry. The prompt should arrive normally.
Then check your database: the checkout should have moved INITIATED to BLOCKED to PENDING to PAID, with no row ever having been written as FAILED. If you see a FAILED in that history, your classifier is still treating a rejected request as a rejected payment.
What people get wrong
The immediate retry loop. The default retry decorator on your HTTP client will fire three attempts within a second or two. For a lock that clears in 60 to 120 seconds, that is guaranteed to fail three times and log three errors that look like a Safaricom outage. If your retry library has no concept of a per-subscriber cooldown, do not use it for this call.
Marking the checkout failed. 1001 means "I could not ask", not "they said no". If you fail the order, the customer may well pay the earlier prompt seconds later, and now you owe them a refund or a manual fulfilment.
Matching on the description string. I have seen if "Unable to lock" in result_desc in production code. The day that text changes, every one of those checkouts silently takes the wrong branch.
A global rate limiter instead of a per-MSISDN one. The lock is per subscriber. Throttling your whole application because one customer has a menu open punishes everybody else and does not help the person who is stuck.
Retrying with a brand new CheckoutRequestID and forgetting the old one. Now you have two outstanding requests and no idea which callback belongs to which order. Keep every request ID you ever issued against the checkout, and reconcile all of them, which is exactly the record-keeping that makes statement reconciliation possible at month end.
When it is still broken
- One specific number gets 1001 every time, for hours. The session is genuinely stuck on the SIM. Ask the customer to open and exit the M-Pesa menu from the SIM toolkit, or to restart the handset, which ends any lingering session. If it persists beyond that, it is a Safaricom-side issue for that MSISDN and support is the only route.
- Many customers get 1001 at once. That is almost never Safaricom. Look for a background job, a webhook retry, or a queue consumer re-processing checkouts and pushing again. Search your logs for two pushes to the same MSISDN within two minutes; if that count is not zero, that is your bug.
- You get 1001 on the first push with no prior attempt from you. Another merchant, or the customer themselves, holds the session. Nothing to fix in code beyond the wait-and-inform flow; this is the case the cooldown exists for.
- The whole flow feels fragile. Then the problem is upstream of 1001. A checkout that depends on a synchronous response from a telco will always be fragile. Build it callback-first, as I set out in the Daraja production guide, and 1001 becomes one more state rather than an incident.
Reference: the Safaricom Daraja developer portal.
Frequently asked questions
- What does M-Pesa error 1001 mean?
- It means Safaricom could not lock the subscriber because that phone number already has an active session, so a new STK push cannot be delivered. The session might be your own earlier push, another merchant's prompt, or a USSD menu the customer dialled themselves. It is a rejection of the request, not a failed payment.
- How long should I wait before retrying after error 1001?
- At least 60 to 90 seconds. The session lock clears on its own once the existing session times out, and retrying inside that window just produces another 1001. Enforce the cooldown per phone number on the server, return the remaining seconds to the front end, and show a countdown so the customer stops tapping the pay button.
- Can a customer still be charged after I get error 1001?
- Yes, if the session that caused the lock was an earlier STK push of your own that the customer then completed. That is why you should never mark the checkout as failed on a 1001. Keep it in a separate blocked state, and before pushing again use STK Push Query on any earlier CheckoutRequestID to confirm whether that attempt succeeded.
- Should I match on ResultCode or ResultDesc when handling Daraja errors?
- Always ResultCode. The numeric codes have been stable for years, while the human-readable ResultDesc text has changed wording between deployments and differs between channels. Any code that does a substring match on the description will silently take the wrong branch the day Safaricom rewords a message.