</>CodeWithKarani

M-Pesa STK Push Callback Never Arrives? Reconcile With the Transaction Status Query API

Karani GeoffreyKarani Geoffrey8 min read

There is a specific kind of 2am panic that only M-Pesa integrators know. A customer swears they paid. Their phone shows the confirmation SMS. Your database says the transaction is still pending. You dig into your logs and find nothing, because the callback from Safaricom that should have told you "this succeeded" never arrived. The money moved. You just do not know it did.

If you take one idea from this article, take this: the STK Push callback is a convenience, not a contract. Safaricom does not guarantee it will reach you, and building your payment flow as if it will is the root cause of every "phantom pending" transaction you will ever chase. The callback is a push notification that might get lost. The source of truth is the Transaction Status Query API, which you pull on your own schedule when you are ready to ask.

This is the reconciliation job nobody publishes, because the Kenyan dev blogs stop at "check your callback URL is whitelisted and returns 200". That advice is correct and necessary and completely insufficient for the case where the URL is perfect and the callback still never comes.

Stop treating the callback as the thing that confirms payment. Persist every STK Push with its CheckoutRequestID the moment you initiate it, marked pending. Let the callback update it if it arrives. Independently, run a reconciliation worker that finds any transaction still pending after 90 seconds and actively queries the TransactionStatusQuery API (or STK Push Query for very recent ones) to get the real state. Make the update idempotent so a late callback and the reconciliation cannot double-count. Alert on anything unresolved past a safe threshold.

The error you keep seeing: ResultCode 1037, DS timeout user cannot be reached

Half the confusion here is that people conflate two different failures: a transaction that genuinely failed, and a callback that got lost for a transaction that succeeded. The one everyone quotes is:

ResultCode 1037: DS timeout user cannot be reached

That code means Safaricom pushed the prompt to the phone and gave up waiting after roughly 60 seconds because the subscriber never acted on it (phone off, out of coverage, old SIM, or they simply ignored it). It is a real result and it usually does arrive by callback. The dangerous cases are the ones where you get no ResultCode at all, because the callback was dropped between Safaricom and your server. Your record sits at pending forever with no closing event.

I have written separately about telling 1019, 1037 and 1032 apart and about what 1037 specifically means when the prompt never reached the phone. This article is about the layer above all of them: what to do when you never learn the code because the message never came.

Why the callback is not guaranteed even when your URL is perfect

Picture the path a result takes. Your server calls Safaricom's STK Push endpoint. Safaricom accepts it and returns a CheckoutRequestID. That is the last thing you are guaranteed. From there the request travels through the Safaricom stack, the prompt goes to the phone over the mobile network, the customer (maybe) enters a PIN, the ledger moves, and then a separate process on Safaricom's side tries to POST a result to your CallBackURL.

Every hop in that return journey can fail independently of whether the money moved:

  • Safaricom's callback dispatcher times out reaching your host during a load spike on their side.
  • Your server was mid-deploy or mid-restart for the 400ms the POST arrived, so it got a connection refused and Safaricom did not retry (or retried into the same window).
  • A proxy, WAF or Cloudflare rule in front of you rejected the POST as suspicious.
  • The callback arrived, your handler threw an exception before committing, and returned 500 - but the money had already moved.

None of these are your callback URL being "wrong". The URL check passes. The money still moved. This is why a design that waits passively for the callback is a design with an unbounded set of stuck transactions.

PUSH (do not trust): Safaricom calls you Your server Safaricom Callback POST may never arrive PULL (source of truth): you ask Safaricom Reconcile job Status Query API answers on demand you initiate the request
The callback is a push you cannot compel. The query is a pull you control. Build your reconciliation on the one you control.

The design: query is the source of truth, callback is an optimisation

Flip the mental model. The callback is a fast-path that, when it arrives, saves you a query. The query is the actual authority. Concretely:

  1. On initiate, write a row: checkout_request_id, merchant_request_id, amount, phone, status PENDING, and a created_at timestamp.
  2. If a callback arrives, update that row by CheckoutRequestID. This is the fast path.
  3. A worker sweeps for rows still PENDING past a threshold and queries Safaricom directly. This is the authority.
  4. Both writes funnel through the same idempotent "settle" function so they cannot conflict.

Step 1: Persist the transaction the instant you initiate it

The initiate response gives you the two IDs you will reconcile against. Store them before you do anything else. If your process dies right after Safaricom accepts the push, you must still have a record to reconcile.

def initiate_stk(phone, amount, account_ref):
    resp = daraja.stk_push(phone=phone, amount=amount, account_ref=account_ref)
    # resp has CheckoutRequestID and MerchantRequestID on success
    db.execute(
        """INSERT INTO mpesa_txn
           (checkout_request_id, merchant_request_id, phone, amount,
            status, created_at)
           VALUES (%s, %s, %s, %s, 'PENDING', NOW())""",
        (resp["CheckoutRequestID"], resp["MerchantRequestID"],
         phone, amount))
    return resp["CheckoutRequestID"]

Step 2: Make settlement a single idempotent function

This is the piece that makes late callbacks safe. Both the callback handler and the reconciliation worker call the same function, which only transitions a row out of PENDING once. If it is already settled, it is a no-op.

def settle_txn(checkout_request_id, result_code, mpesa_receipt=None):
    # Conditional update: only fires while status is still PENDING.
    rows = db.execute(
        """UPDATE mpesa_txn
              SET status = %s,
                  result_code = %s,
                  mpesa_receipt = %s,
                  settled_at = NOW()
            WHERE checkout_request_id = %s
              AND status = 'PENDING'""",
        (("SUCCESS" if result_code == 0 else "FAILED"),
         result_code, mpesa_receipt, checkout_request_id))
    if rows.rowcount == 0:
        return  # already settled by the other path; do nothing
    if result_code == 0:
        fulfil_order(checkout_request_id)  # also must be idempotent

The AND status = 'PENDING' in the WHERE clause is the whole trick. The database decides the winner atomically. Whichever path (callback or reconciliation) reaches the row first flips it; the second gets rowcount == 0 and quietly stops. This is the same discipline I describe in handling M-Pesa callback duplicates with idempotency.

Step 3: The reconciliation worker

Run this every 30 to 60 seconds. It picks up anything stuck and asks Safaricom what actually happened. For transactions under a few minutes old, the STK Push Query (stkpushquery/v1/query) is the natural first call; for older or unclear ones, the Transaction Status Query (CommandID: TransactionStatusQuery) is the durable authority because it works long after the STK query stops returning useful state.

def reconcile_pending():
    stuck = db.query(
        """SELECT checkout_request_id, created_at
             FROM mpesa_txn
            WHERE status = 'PENDING'
              AND created_at < NOW() - INTERVAL 90 SECOND""")
    for txn in stuck:
        result = daraja.stk_query(txn["checkout_request_id"])
        # ResultCode 0 = success, non-zero = failed/cancelled/timeout.
        # 1032 = cancelled by user, 1037 = timeout unreachable.
        code = result.get("ResultCode")
        if code is not None:
            settle_txn(txn["checkout_request_id"], int(code),
                       result.get("MpesaReceiptNumber"))
        # If Safaricom answers 500.001.1001 "transaction being processed",
        # leave it PENDING and let the next sweep retry.

Notice what happens on a lost callback: the money moved, the callback vanished, the row sits PENDING, and 90 seconds later the worker queries, gets ResultCode 0, and settles it exactly as the callback would have. You never needed the callback at all.

The ambiguous ResultCodes and what each really means

When you query, you will meet a small set of codes that people misread. The one that trips everyone is that "processing" is not a failure and must not be settled.

CodeMeaningWhat to do
0Success, money movedSettle as SUCCESS, fulfil once
1032Request cancelled by userSettle as FAILED, final
1037Timeout, user unreachableSettle as FAILED, final
1Insufficient balanceSettle as FAILED, final
2001Wrong PIN / initiator errorSettle as FAILED, final
500.001.1001Transaction still being processedDo NOT settle, retry next sweep

Treat 500.001.1001 as "ask again later", never as failed. Marking a still-processing transaction as failed and then having the money settle is exactly how you refund a customer who did pay.

Verification: prove the reconciliation actually closes the gap

You verify this the way you verify any safety net: by removing the thing you no longer trust and confirming the system still settles. In sandbox or a staging shortcode:

  1. Point your CallBackURL at a black hole (a route that returns 200 and discards the body). Now no callback ever updates a row.
  2. Run a successful STK Push. Confirm the row is written PENDING.
  3. Wait past your threshold and confirm the reconciliation worker flips it to SUCCESS purely from the query.
-- Nothing should linger here for more than a couple of minutes.
SELECT checkout_request_id, status,
       TIMESTAMPDIFF(SECOND, created_at, NOW()) AS age_seconds
FROM mpesa_txn
WHERE status = 'PENDING'
ORDER BY created_at;

If that query returns an empty set even with callbacks disabled, your reconciliation is the source of truth and you are done. If rows pile up, your worker is not running or not querying correctly.

What people get wrong

"Just make sure your callback URL is whitelisted." Necessary, not sufficient. It fixes the case where callbacks never worked. It does nothing for the case where they work 99% of the time and silently drop the other 1%, which at any real volume is money left on the floor daily.

"Retry the STK Push if you did not get a callback." This is how you double-charge people. If the first push actually succeeded and you re-push, the customer pays twice. Never re-initiate to resolve uncertainty. Query to resolve uncertainty; re-initiate only when the query proves the first one failed.

"Increase the callback timeout / add retries on your side." You cannot make Safaricom retry harder. The retry that matters is your pull, on your schedule.

When it is still broken

If transactions still linger unresolved after adding reconciliation:

  • Alert on age, not just failure. Any row PENDING past, say, 10 minutes is an anomaly. Page yourself. A stuck transaction is either a bug in your worker or a genuine Safaricom incident, and both need eyes.
  • Check your query credentials. The Transaction Status Query needs a valid Initiator name and SecurityCredential. If those are wrong you get an initiator error and your worker silently fails to settle anything. See the stale-initiator-certificate problem.
  • Confirm the worker is actually scheduled and not silently dead. A reconciliation job that stopped running three weeks ago looks exactly like everything working, until it does not.
  • Reconcile against the M-Pesa statement daily as a backstop, matching receipts to your rows. My statement reconciliation approach catches anything both the callback and the live query missed.

The callback will still be right most of the time, and when it is, you save a query. But you will never again stare at a pending row wondering whether a real human lost real money because a POST got dropped on the internet. You ask Safaricom, and Safaricom tells you.

Frequently asked questions

Why does my M-Pesa STK Push callback never arrive even though the URL returns 200?
Safaricom does not guarantee callback delivery. The result POST can be dropped by a load spike on their side, a restart or deploy on yours, or a proxy or WAF in front of your server, all while the money still moved. A correctly configured URL only fixes callbacks that never worked at all; it does not make delivery guaranteed. Treat the callback as a fast path and reconcile stuck transactions by querying Safaricom directly.
What does ResultCode 1037 DS timeout user cannot be reached mean?
It means Safaricom sent the STK prompt to the phone but gave up after about 60 seconds because the subscriber never acted on it. Common causes are the phone being off, out of coverage, using an old SIM, or the user ignoring the prompt. It is a genuine final failure, so settle the transaction as FAILED. It is different from a lost callback, where the money actually moved but the result never reached you.
How do I stop a late M-Pesa callback from double-counting a transaction I already reconciled?
Funnel both the callback handler and the reconciliation worker through one settle function that updates the row only WHERE status = 'PENDING'. The database transitions the row out of pending atomically, so whichever path arrives first wins and the second becomes a no-op. Make the downstream fulfilment idempotent too, keyed on the CheckoutRequestID or M-Pesa receipt.
Should I retry the STK Push if I did not receive a callback?
No. Re-initiating is how you double-charge customers, because the first push may have actually succeeded. To resolve uncertainty, query the transaction using the STK Push Query or Transaction Status Query API. Only re-initiate a new push once the query has proven the original one genuinely failed.
#M-Pesa#Daraja#STK Push#Reconciliation#Idempotency#Payments
Keep reading

Related articles