Bonaventure OgetoBy Bonaventure Ogeto|

M-Pesa STK Push Timeout Handling Compared Across Providers

When an M-Pesa STK push times out, the outcome depends on your provider. Paystack handles the timeout internally through its checkout page and notifies you via webhook when the charge fails or succeeds after a retry. With direct Daraja, you own the entire 30-second timeout window and must poll the Query API or wait for the callback yourself. IntaSend and Pesapal each have their own webhook-based flows. The key difference is how much timeout logic you build yourself versus how much the provider absorbs.

The 30-Second Window

When Safaricom sends an STK push to a customer's phone, a dialog appears asking them to enter their M-Pesa PIN. That dialog has a timeout. If the customer does not act, it disappears. The transaction expires.

The exact timeout varies slightly depending on the phone, the network load, and the Safaricom backend state. But the practical number developers work with is roughly 30 seconds. Some developers report shorter windows during peak hours. Others have seen it stretch slightly longer on quiet evenings. The point is that this is not a reliable, fixed number you can set your clock by.

What happens during those 30 seconds matters for your entire payment flow. And what happens after those 30 seconds pass without a PIN entry is where providers diverge completely.

Here are the common timeout scenarios:

  • Customer ignores the prompt. They are busy, distracted, or did not notice the notification. The dialog expires. No money moves.
  • Customer dismisses the prompt. They actively tap "Cancel" or swipe it away. The transaction fails immediately rather than waiting for the full timeout.
  • Customer's phone is off or unreachable. The STK push never arrives. From Safaricom's side, this looks like a timeout. From the customer's side, nothing happened.
  • Network delay between Safaricom and the phone. The push arrives late, the customer enters their PIN, but by the time the response reaches Safaricom's server, the original request has already timed out on the API side. This is the dangerous one. Money can move even though the initiating system thinks the transaction failed.

That last scenario is why timeout handling is not just about showing a "try again" button. It is about money integrity. Let us look at how each provider deals with it.

How Paystack Handles the Timeout

Paystack's approach is to keep the timeout complexity on their side of the wall. When you use Paystack Checkout (the popup or redirect), the checkout page manages the M-Pesa flow end to end. The customer sees a screen that says something like "Check your phone for the M-Pesa prompt." The page polls Paystack's backend for status updates.

If the STK push times out, Paystack's checkout page shows the customer a message and may offer a retry option. You, the developer, do not build this polling logic. You do not manage the 30-second countdown. You do not decide when to show a retry button. Paystack's frontend handles it.

If you use the Charge API directly (not the checkout popup), Paystack returns a pay_offline status in the response. This means the STK push was sent, and you should wait for the webhook. When the charge succeeds, you get charge.success. When it fails (including a timeout), the charge does not succeed, and the transaction stays in a pending or failed state.

What Paystack does not do is send you a "charge.timeout" event. There is no dedicated timeout webhook. Instead, a timed-out M-Pesa charge simply never transitions to success. You handle it by:

  1. Setting your own server-side timer. If you initiated a charge and have not received a charge.success webhook within a reasonable window (say, 2 to 5 minutes), treat it as likely failed.
  2. Polling the Verify Transaction endpoint. You can call GET /transaction/verify/:reference to check the current status of the transaction. If it is still pending after your timeout window, show the customer a retry option.
  3. Never auto-retrying. Do not send another Charge API request automatically. Let the customer decide whether to try again.
// Polling for M-Pesa charge status on Paystack
async function checkChargeStatus(reference, maxAttempts = 10) {
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    const res = await fetch(
      `https://api.paystack.co/transaction/verify/${reference}`,
      {
        headers: {
          Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
        },
      }
    );

    const data = await res.json();

    if (data.data.status === 'success') {
      return { paid: true, data: data.data };
    }

    if (data.data.status === 'failed') {
      return { paid: false, reason: data.data.gateway_response };
    }

    // Still pending. Wait before polling again.
    await new Promise((resolve) => setTimeout(resolve, 5000));
  }

  // Exhausted all attempts. Treat as timeout.
  return { paid: false, reason: 'timeout' };
}

The advantage of Paystack's model: you write less timeout code. The disadvantage: you have less visibility into exactly what is happening during the 30-second window. For most applications, this tradeoff is worth it.

How Direct Daraja Handles the Timeout

With Safaricom's Daraja API, you own everything. You send the STK Push request via the Lipa Na M-Pesa Online endpoint. Safaricom sends the push to the customer's phone. Then you wait.

Daraja gives you two ways to learn the outcome:

  1. Callback URL. When the customer enters their PIN (or the push times out), Safaricom sends a POST request to the callback URL you provided in the original STK Push request. The callback includes a ResultCode. Code 0 means success. Code 1032 means the user cancelled. Code 1037 means the request timed out.
  2. STK Push Query API. You can poll the /mpesa/stkpushquery/v1/query endpoint using the CheckoutRequestID from the original request. This returns the current status of the transaction.

The problem is that neither of these is perfectly reliable. The callback can fail to arrive if Safaricom's servers are under load or if your server was briefly unreachable. The Query API can return "pending" for extended periods during system congestion.

Here is a typical Daraja timeout handling pattern:

// Daraja STK Push with timeout handling
const axios = require('axios');

async function initiateStkPush(phone, amount) {
  const timestamp = new Date()
    .toISOString()
    .replace(/[-T:.Z]/g, '')
    .slice(0, 14);

  const password = Buffer.from(
    `${process.env.SHORTCODE}${process.env.PASSKEY}${timestamp}`
  ).toString('base64');

  const response = await axios.post(
    'https://api.safaricom.co.ke/mpesa/stkpush/v1/processrequest',
    {
      BusinessShortCode: process.env.SHORTCODE,
      Password: password,
      Timestamp: timestamp,
      TransactionType: 'CustomerPayBillOnline',
      Amount: amount,
      PartyA: phone,
      PartyB: process.env.SHORTCODE,
      PhoneNumber: phone,
      CallBackURL: `${process.env.BASE_URL}/api/mpesa/callback`,
      AccountReference: 'OrderRef123',
      TransactionDesc: 'Payment',
    },
    {
      headers: {
        Authorization: `Bearer ${await getAccessToken()}`,
      },
    }
  );

  return response.data.CheckoutRequestID;
}

// Poll the Query API after the timeout window
async function queryStkStatus(checkoutRequestId) {
  const timestamp = new Date()
    .toISOString()
    .replace(/[-T:.Z]/g, '')
    .slice(0, 14);

  const password = Buffer.from(
    `${process.env.SHORTCODE}${process.env.PASSKEY}${timestamp}`
  ).toString('base64');

  const response = await axios.post(
    'https://api.safaricom.co.ke/mpesa/stkpushquery/v1/query',
    {
      BusinessShortCode: process.env.SHORTCODE,
      Password: password,
      Timestamp: timestamp,
      CheckoutRequestID: checkoutRequestId,
    },
    {
      headers: {
        Authorization: `Bearer ${await getAccessToken()}`,
      },
    }
  );

  return response.data;
}

The key difference from Paystack: you decide when to poll, how many times to poll, and what to do when the callback does not arrive. You also need to build the customer-facing UI that shows the waiting state, the timeout message, and the retry button. None of that is provided for you.

Common Daraja ResultCode values you will encounter during timeouts:

  • 0 - Success (customer paid)
  • 1 - Insufficient balance
  • 1032 - Request cancelled by user
  • 1037 - DS timeout (the request timed out)
  • 2001 - Wrong PIN entered
  • 1 or 1001 (sometimes) - Unable to lock subscriber (phone busy or unreachable)

Daraja gives you more control, but that control comes with responsibility. Every edge case is yours to handle.

IntaSend and Pesapal Timeout Behavior

IntaSend and Pesapal sit in the same category as Paystack: they are payment gateways that abstract M-Pesa for you. But their timeout behavior is not identical to Paystack's, and the differences matter when you are evaluating providers.

IntaSend

IntaSend triggers the STK push through their API. You send a collection request with the customer's phone number and amount. IntaSend sends you a webhook when the charge completes or fails. During the timeout window, the transaction sits in a "PENDING" state.

IntaSend's approach to timeouts:

  • They send a failure webhook when the STK push times out. Unlike Paystack (where you infer failure from the absence of success), IntaSend explicitly notifies you of the timeout.
  • Their checkout page (if you use the hosted version) handles the waiting UI and retry prompts.
  • If you use the API directly, you poll their status endpoint using the invoice ID. The flow is similar to the Paystack Verify endpoint pattern.
  • IntaSend is Kenyan-built, so their M-Pesa integration is a primary focus rather than one of many country modules. Their timeout behavior tends to be well-tuned for Kenyan network conditions.

Pesapal

Pesapal handles M-Pesa as one of several payment options in their checkout flow. When a customer selects M-Pesa and enters their phone number, Pesapal triggers the STK push. The customer completes the payment on their phone.

Pesapal's timeout handling:

  • Pesapal uses an IPN (Instant Payment Notification) callback model. You register an IPN URL, and Pesapal sends you a notification when the transaction status changes.
  • Timeout handling is largely internal to Pesapal's checkout. The customer interacts with Pesapal's payment page, which shows status updates and retry options.
  • You can also query the Transaction Status API to check whether a payment completed. This is your fallback for cases where the IPN does not arrive.
  • Pesapal has been operating in Kenya longer than Paystack. Their M-Pesa integration is mature, but their API design reflects an older architectural style. The IPN model works, but it requires more setup than a simple webhook URL.

Here is a quick comparison table:

Behavior Paystack Daraja (Direct) IntaSend Pesapal
Who triggers STK push Paystack You IntaSend Pesapal
Timeout UI provided Yes (Checkout) No Yes (hosted) Yes (hosted)
Explicit timeout notification No (infer from no success) Yes (ResultCode 1037) Yes (failure webhook) Yes (IPN notification)
Status polling endpoint Verify Transaction API STK Query API Status API Transaction Status API
Retry built into hosted UI Yes N/A Yes Yes
Timeout logic you build Minimal All of it Minimal Minimal

The gateway providers (Paystack, IntaSend, Pesapal) all reduce your timeout burden. Direct Daraja gives you full control at the cost of full responsibility. The right choice depends on whether your team wants to own that complexity or outsource it.

Customer Messaging During the Timeout Window

The technical handling of timeouts is only half the problem. The other half is what the customer sees on their screen while they wait. Get this wrong and they will close the tab, try to pay again (potentially causing a double charge), or abandon the purchase entirely.

Good timeout messaging follows a sequence:

  1. Immediately after initiating. Show: "Check your phone for the M-Pesa prompt. Enter your PIN to complete the payment." Be specific. Do not say "processing." The customer needs to know they have an action to take on their phone.
  2. After 10 to 15 seconds. If you have not received confirmation yet, show: "Still waiting for your M-Pesa confirmation. Make sure you entered your PIN on the prompt." This reassures the customer that waiting is normal.
  3. After 30 to 45 seconds. The push has likely timed out. Show: "The M-Pesa prompt may have expired. You can try again." Offer a clear "Resend M-Pesa prompt" button.
  4. After a failed retry. Offer alternatives: "Having trouble with M-Pesa? You can also pay by card or Pesalink." Do not trap the customer in an M-Pesa loop if the channel is having issues.

Messages to avoid:

  • "Payment processing..." (too vague, customer does not know they need to act on their phone)
  • "Error occurred" (frightening, especially when money might have moved)
  • "Transaction failed" before you have actually confirmed failure (premature)
  • Any message that says their money was taken when you do not actually know that yet

If you use Paystack Checkout, this messaging is built in. If you use the Charge API directly or any provider's raw API, you build this UI yourself. It is not glamorous work, but it directly affects whether the customer completes the purchase or gives up.

Retry Strategies That Work

The instinct when an STK push times out is to send another one immediately. Do not do that. Here is why, and what to do instead.

Why automatic retries are dangerous:

  • Safaricom may still be processing the first push. If the first one succeeds late and your retry also succeeds, the customer gets charged twice.
  • Multiple STK pushes hitting the phone at once confuse the customer. They see two prompts, do not know which one to enter their PIN on, and may complete both.
  • Safaricom sometimes returns a "locked subscriber" error when there is already a pending STK push on the phone number. Sending retries too fast hits this wall.

What works instead:

  1. Wait for the previous push to definitively expire. Before sending a retry, confirm that the first transaction failed. On Daraja, poll the Query API. On Paystack, verify the transaction. On IntaSend or Pesapal, check the status endpoint. Only send a retry when you know the first attempt is dead.
  2. Let the customer trigger the retry. Show a "Resend prompt" button. Do not auto-send. The customer knows whether their phone received the push or not. Let them decide.
  3. Use a new reference for each retry. Do not reuse the same transaction reference. If you use Daraja, send a new STK Push request with a new reference. If you use Paystack, initialize a new transaction. This makes reconciliation cleaner because each attempt has a distinct identity.
  4. Cap the retry count. Three attempts is reasonable. After three failed STK pushes, the customer's phone or M-Pesa account may have an issue. Offer an alternative payment method instead of hammering M-Pesa.
// Retry flow with proper safeguards
async function handleMpesaRetry(orderId, phone, amount) {
  // Step 1: Check if the previous attempt actually failed
  const previousAttempt = await db.getLatestPaymentAttempt(orderId);

  if (previousAttempt && previousAttempt.status === 'pending') {
    // Verify with provider before retrying
    const currentStatus = await checkChargeStatus(previousAttempt.reference);

    if (currentStatus.paid) {
      // The previous attempt actually succeeded. Do not retry.
      await db.markOrderPaid(orderId, previousAttempt.reference);
      return { success: true, message: 'Payment already completed' };
    }

    // Mark the old attempt as expired
    await db.markAttemptExpired(previousAttempt.reference);
  }

  // Step 2: Check retry count
  const attemptCount = await db.countAttempts(orderId);
  if (attemptCount >= 3) {
    return {
      success: false,
      message: 'Maximum retry attempts reached. Try a different payment method.',
    };
  }

  // Step 3: Initiate new charge with fresh reference
  const newReference = `order_${orderId}_attempt_${attemptCount + 1}_${Date.now()}`;
  const result = await initiateCharge(phone, amount, newReference);

  await db.savePaymentAttempt({
    orderId,
    reference: newReference,
    status: 'pending',
    attemptNumber: attemptCount + 1,
  });

  return { success: true, reference: newReference };
}

The pattern works regardless of provider. The specifics of how you check status and initiate the charge change, but the logic stays the same: confirm the previous attempt is dead, cap retries, use fresh references, and let the customer drive the retry.

Polling vs Webhooks During the Timeout

Every provider gives you two paths to learn the outcome: a push notification (webhook or callback) and a pull endpoint (status query or verify API). During the timeout window, the question is which one to rely on.

Webhooks are the source of truth. When the webhook arrives and you verify its signature, you know the outcome. The transaction succeeded or failed. Act on it.

Polling is the safety net. If the webhook does not arrive (server was down, network blip, provider had a delivery failure), polling catches what the webhook missed.

The practical approach is to use both:

  1. Set up your webhook handler to process charge.success (Paystack), the Daraja callback, or the IntaSend/Pesapal notification. Make it idempotent so processing the same event twice does not cause problems.
  2. Run a polling job that checks pending transactions. If a transaction has been pending for more than your timeout window (say, 5 minutes), poll the provider's status endpoint.
  3. If polling finds a completed transaction that the webhook missed, process it the same way the webhook handler would.
// Sweeper job for pending transactions
async function sweepPendingTransactions() {
  const pendingCutoff = new Date(Date.now() - 5 * 60 * 1000); // 5 minutes ago
  const pending = await db.getPendingTransactionsBefore(pendingCutoff);

  for (const txn of pending) {
    try {
      const res = await fetch(
        `https://api.paystack.co/transaction/verify/${txn.reference}`,
        {
          headers: {
            Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
          },
        }
      );

      const data = await res.json();

      if (data.data.status === 'success') {
        await processSuccessfulPayment(txn.reference, data.data);
      } else if (data.data.status === 'failed') {
        await db.markTransactionFailed(txn.reference, data.data.gateway_response);
      }
      // If still pending after 5 minutes, log it for manual review
      // and try again on the next sweep
    } catch (err) {
      console.error(`Sweep error for ${txn.reference}:`, err.message);
    }
  }
}

// Run every 2 minutes
setInterval(sweepPendingTransactions, 2 * 60 * 1000);

This belt-and-suspenders approach catches the cases where webhooks fail silently. It is especially important with M-Pesa because the Safaricom network can delay callbacks during peak hours (end of month, Friday evenings, public holidays). Do not trust any single notification channel to be 100% reliable.

What Happens to Money in Limbo

The scariest timeout scenario is when the customer gets debited but your system does not receive confirmation. From the customer's perspective, they paid. From your system's perspective, nothing happened. The money is in limbo.

This happens more often than you might expect. The typical sequence:

  1. STK push arrives on the customer's phone.
  2. Customer enters their PIN just before the timeout expires.
  3. Safaricom processes the debit from the customer's M-Pesa wallet.
  4. The callback or webhook delivery fails (your server was temporarily down, or the provider's callback system dropped it).
  5. Your system marks the transaction as timed out. The customer's M-Pesa balance is reduced.

Where the money actually sits depends on the provider:

  • Paystack. The money lands in Paystack's pool account. Paystack tracks it as a completed transaction. Your sweeper job or the next webhook delivery attempt will catch it. If it stays unmatched, it shows up in your Paystack dashboard as a completed transaction with no corresponding action on your side.
  • Direct Daraja. The money lands in your paybill or till account. You can see it in your M-Pesa statement. Your reconciliation job should match it by amount, phone number, and timestamp against your pending orders.
  • IntaSend / Pesapal. The money lands in the provider's holding account. They track it as a completed collection. Their webhook retry mechanism should eventually deliver the notification, or you can query their status endpoint to find it.

In all cases, the money is not lost. It is sitting somewhere trackable. The problem is that your application does not know about it. The fix is reconciliation.

Reconciliation After Timeouts

Reconciliation is how you catch the transactions that slipped through the cracks. For M-Pesa timeouts specifically, you need two reconciliation layers:

Layer 1: Real-time sweep. The sweeper job described above. It runs every few minutes, checks pending transactions against the provider's verify/status endpoint, and processes anything that completed without a webhook. This catches most timeout cases within minutes.

Layer 2: Daily reconciliation. At the end of each day, compare your internal transaction records against the provider's records. On Paystack, export the day's transactions from the dashboard or use the Transaction Export API. On Daraja, pull your M-Pesa statement. On IntaSend or Pesapal, use their reporting API or dashboard export.

What you are looking for in daily reconciliation:

  • Transactions in the provider's records but not in yours. These are the limbo payments. The customer paid, the provider knows, but your system missed it. These need manual or automated resolution: grant the customer their value and update your database.
  • Transactions in your records but not in the provider's. These could be test transactions, abandoned attempts that your system did not clean up, or (rarely) fraudulent attempts. Investigate each one.
  • Amount mismatches. The provider recorded KES 1,500 but your system expected KES 1,000 for that order. This should not happen with properly coded integrations, but it does happen when currency conversion or fee deduction logic has bugs.

For a full reconciliation implementation with Paystack, see Reconciling M-Pesa Payments Received Through Paystack. For the general reconciliation pattern, see Building a Daily Paystack Reconciliation Job.

Timeout handling is not a feature you build once and forget. It is an ongoing process. Safaricom's infrastructure changes, providers update their timeout behavior, and Kenyan network conditions vary by time of day and time of month. Monitor your timeout rates. If they spike, investigate whether it is a network issue, a provider issue, or a bug in your code. The reconciliation job is your safety net for the cases you could not predict.

Practical Recommendations

If you are starting a new project and choosing a provider, here is the straightforward advice:

Use Paystack Checkout (or IntaSend/Pesapal hosted) if you want minimal timeout code. The hosted checkout pages handle the waiting UI, the retry prompts, and the status polling. You just wait for the webhook. This is the right choice for most applications, especially if you are a small team and payment integration is not your core product.

Use direct Daraja if you need full control over the M-Pesa experience. You will write more code, but you will know exactly what is happening at every step. This is the right choice for high-volume payment platforms, M-Pesa-first products, and teams that need to customize every aspect of the payment flow.

Regardless of provider:

  • Build a sweeper job for pending transactions. Run it every few minutes.
  • Build a daily reconciliation job. Run it every night.
  • Never auto-retry STK pushes. Let the customer trigger retries.
  • Use distinct references for every attempt. No reference reuse.
  • Store every payment attempt, not just successful ones. You need the failed and timed-out records for debugging and reconciliation.
  • Show clear, honest messages to the customer at every stage. "Check your phone" beats "Processing." "The prompt may have expired, try again" beats "Error."

The 30-second STK push timeout is a constraint of the M-Pesa system. No provider can eliminate it. But the right architecture turns it from a source of lost revenue into a handled edge case that your system recovers from automatically.

To learn how Paystack specifically handles callbacks and webhooks for Kenyan mobile money, including M-Pesa and Pesalink, see Paystack Callback Handling for Kenyan Mobile Money.

Key Takeaways

  • The M-Pesa STK push has a roughly 30-second window. If the customer does not enter their PIN in that window, the transaction expires. Every provider handles this expiry differently.
  • Paystack absorbs the timeout complexity. Its checkout page manages retries and status polling internally. You receive a webhook when the charge succeeds or fails. You do not build timeout logic.
  • With direct Daraja, you own the timeout. You must poll the STK Push Query API or wait for the callback URL to fire. If neither arrives, you need a reconciliation sweep.
  • IntaSend and Pesapal both use webhook-based notification. The differences are in their retry behavior, how quickly they send failure notifications, and what status values they return during the pending window.
  • Money in limbo is real. A customer can be debited by Safaricom even when your system thinks the transaction timed out. Always run a reconciliation job that catches these orphaned payments.
  • Never retry an STK push automatically without the customer requesting it. Sending multiple pushes to a phone that already has one pending creates confusion and potential double charges.
  • Your customer-facing messaging during the timeout window matters as much as your backend logic. Tell the customer what is happening. Tell them what to do if the prompt does not arrive.

Frequently Asked Questions

What happens if the customer enters their M-Pesa PIN right at the timeout boundary?
Safaricom may still process the debit even if the API-side timeout has elapsed. The money leaves the customer's wallet, but the callback or webhook may not fire properly. This is why reconciliation jobs are essential. The money is not lost. It lands in the provider's pool account (Paystack, IntaSend, Pesapal) or your own paybill account (Daraja). Your reconciliation sweep picks it up and matches it to the pending order.
How many times should I retry an STK push before giving up?
Three attempts is a practical limit. After three failed STK pushes to the same phone number, the issue is likely on the customer's end (phone off, M-Pesa account locked, insufficient balance, or network problems). Offer an alternative payment method like card or Pesalink rather than continuing to send pushes.
Can I set a custom timeout duration for the STK push?
No. The STK push timeout is controlled by Safaricom, not by your application or your payment provider. You cannot extend or shorten the window. You can only control how your application responds to the timeout. All the providers discussed in this article work within the same Safaricom-imposed window.
Does Paystack send a webhook when an M-Pesa charge times out?
Paystack does not send a dedicated "charge.timeout" webhook event. A timed-out M-Pesa charge simply does not transition to success. You detect the timeout by checking the transaction status via the Verify Transaction API. If the transaction remains in a non-success state after your timeout window, treat it as failed and offer the customer a retry.
Which provider has the best M-Pesa timeout handling for Kenyan developers?
There is no single best answer. Paystack and IntaSend both handle the timeout complexity well through their hosted checkout pages. Pesapal has mature M-Pesa support with longer operational history in Kenya. Direct Daraja gives you the most control but the most work. If you want minimal timeout code and multi-method checkout, any of the three gateways works. If you need granular control over every timeout scenario, go with direct Daraja.

Ready to build real-world apps?

Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.

Apply to the McTaba Marathon