Payment rails · 3 min read · 14 July 2026

Why your STK push times out, and what to do about it

Daraja's STK push has three distinct failure modes that all surface as the same timeout. Here is how to tell them apart and design around each one.

Most teams integrating M-Pesa for the first time treat the STK push as a request/response call. It is not. It is a request that triggers a separate, asynchronous callback, and almost every production incident traces back to code written as if those two things were one.

The three failure modes

They look identical from the client. They are not the same problem.

The push never reaches the handset. The API accepted your request and returned a CheckoutRequestID, but nothing appeared on the customer's phone. Usually a SIM-level issue or a subscriber on a device that cannot render the prompt.

The customer never responds. The prompt arrived and was ignored or dismissed. Daraja will eventually time this out on its own schedule, not yours.

The callback never reaches you. The customer paid. The money moved. Your endpoint returned a non-2xx, or was unreachable, or sat behind a URL that was never publicly resolvable in the first place.

The third one is the dangerous case, because the customer's balance changed and your system does not know.

Design for the callback, not the push

The rule that removes most of this class of bug: the STK push response is an acknowledgement, never a confirmation. Persist the transaction as Pending the moment you get a CheckoutRequestID, and let only the callback move it to Completed.

// The push tells you the request was accepted. Nothing more.
var response = await _daraja.StkPushAsync(request, ct);

await _transactions.AddAsync(new PaymentTransaction {
    CheckoutRequestId = response.CheckoutRequestID,
    MerchantRequestId = response.MerchantRequestID,
    Status            = PaymentStatus.Pending,
    InitiatedAt       = _clock.UtcNow,
}, ct);

Then reconcile, because callbacks are lost

A callback endpoint alone is not enough. Run a background job that queries the transaction status for anything still Pending past a threshold — two minutes is a reasonable starting point — and settle it from the query result.

Two things matter in that job. Make the callback handler idempotent on CheckoutRequestID, because you will occasionally process both the callback and the query result for the same payment. And do not delete pending records on timeout; mark them Unresolved and surface them, because a payment your system gave up on is still a payment your customer made.

The part nobody documents

Daraja's callback does not retry meaningfully. If your endpoint is down for a deploy, those callbacks are gone. This is the single strongest argument for the reconciliation job being a first-class part of the design rather than a safety net bolted on after the first incident.