Skip to main content
Infrastructure Payments & M-PESA 10 min read

M-PESA Webhooks vs Polling: When Each Actually Wins

Daraja callbacks are not always reliable. When to poll the Transaction Status endpoint instead, how often, and how to avoid duplicate ledger entries.

By Jay Kiharani

M-PESA Webhooks vs Polling: When Each Actually Wins

Every M-PESA integration we have rescued has the same shape of bug: the engineer trusted the Daraja callback. The callback arrived late, arrived twice, or did not arrive at all, and the system was not ready for any of those cases.

If you are building or maintaining an M-PESA integration, the question you actually need to answer is not "do I use webhooks or polling" - it is "how do I combine both so that money never falls through the gap".

This post is the practical answer. We have shipped this pattern in production across retail, schools, professional firms, and marketplaces, and it is the only one we have seen survive real Kenyan traffic.

What each pattern is, briefly

Webhook (callback)

You set a public URL with Safaricom. When an M-PESA transaction reaches a terminal state, Daraja sends an HTTP POST to that URL with the result. Your handler reads the payload, updates your ledger, returns HTTP 200, and the customer moves on.

This is what every "5-minute M-PESA tutorial" teaches. It is also what every "why is our payment system broken" support ticket is built on.

Polling (Transaction Status)

You call Daraja's TransactionStatusQuery endpoint with a transaction reference. Daraja replies with the current state - completed, pending, failed, or not found. You decide what to do with the answer.

Polling is slower, costs an API call per query, and feels unnatural to engineers raised on event-driven design. But it has one property webhooks do not: you decide when to ask.

Why webhook-only fails

The official Daraja documentation describes the callback as if it is a guarantee. Production says otherwise:

  1. Callbacks arrive late. Under load - the start of the school term, payday, Black Friday - Safaricom's callback delivery can lag from a few seconds to several minutes. A customer who pressed pay 30 seconds ago does not want to stare at a spinner for two minutes.

  2. Callbacks get retried. Daraja retries on non-2xx responses, and occasionally even on 2xx responses if its internal state is uncertain. If your handler is not idempotent, the second delivery double-credits the order.

  3. Callbacks get lost. Network blips, your server restarting mid-request, your reverse proxy returning a 502 because Octane was rolling, a misconfigured Cloudflare rule. Any of these and the callback is gone forever. Safaricom does not have a "resend" button.

  4. Callbacks lie about state, occasionally. The ResultCode field has been observed to disagree with the actual transaction state. Rare, but it happens, usually when a callback for a different request gets routed to the wrong listener due to a stale CheckoutRequestID.

A webhook-only integration is fine for demos. In production, it is the reason "phantom failed payments" became a phrase in our vocabulary.

Why polling-only fails

The mirror failure mode:

  1. Polling is rate-limited. Daraja throttles the Transaction Status endpoint per shortcode. If you poll every transaction every five seconds, you will get throttled at any reasonable volume, and your polling will start to return stale or no data.

  2. Polling adds latency. Customers want feedback now. If your first poll fires 10 seconds after the STK push, every successful payment looks slow.

  3. Polling costs API calls. Each query is a request that Safaricom may eventually bill you for at higher volumes. Even when the cost is zero, the cumulative latency budget is not free.

  4. You still need to handle late completion. A customer who has 30 seconds left to enter their PIN can complete the payment well after you stopped polling. If you never check again, the money lands in your Paybill and nobody notices.

A polling-only integration is what teams end up with after their callback URL stops working twice in a row and they swear off webhooks. It works, but it scales badly and feels sluggish.

The hybrid pattern that works

The pattern we ship is straightforward: treat the callback as a hint, treat the Transaction Status endpoint as the source of truth, and have a reconciliation job catch what both missed.

Step 1: Initiate

The customer triggers the payment. Your system stores a pending transaction row with the CheckoutRequestID Daraja returns, the amount, the phone, the order ID, and a created_at timestamp.

The pending row is critical. It is what every later step looks up against. No pending row, no integrity.

Step 2: Wait for the callback

You listen for the callback. If it arrives within ~60 seconds:

  • Look up the CheckoutRequestID in your pending transactions.
  • If the row does not exist, return 200 to Daraja and log the orphan - do not insert a new row. An unsolicited callback for an unknown checkout is either a misroute or a replay; ignore it gracefully.
  • If the row exists and is already in a terminal state, return 200 to Daraja and stop. You already processed this. Idempotency wins.
  • If the row exists and is pending, update it from the callback payload, mark the order/fee/booking accordingly, and return 200.

The first three bullets are the difference between idempotent and broken. Make them explicit code, not assumptions.

Step 3: Poll the late ones

A background job runs every 30-60 seconds. It queries all pending transactions older than 60 seconds and younger than the cutoff (we use 10 minutes - the practical M-PESA timeout window):

for each pending tx older than 60s:
  result = Transaction Status query for tx.CheckoutRequestID
  if result is completed: mark completed, settle the order
  if result is failed: mark failed, release the cart
  if result is still pending: leave it, retry next tick
  if result is not found: mark unknown, escalate

Polling schedule that works in practice

Start the polling job at +60 seconds after STK initiation, then every 30 seconds, with exponential backoff to 60 seconds, 120 seconds, and stop at 10 minutes. Mark anything still pending at 10 minutes as timed_out and let reconciliation handle it.

Step 4: Reconcile against the M-PESA statement

Every morning at a quiet hour, a reconciliation job:

  • Downloads or reads the previous day's Paybill or Till statement.
  • For each entry in the statement, looks up your internal transaction by the M-PESA receipt number.
  • Flags anything in the statement that is not in your ledger - those are payments that completed at Safaricom but never closed at your end.
  • Flags anything in your ledger marked as completed that is not in the statement - those are false positives, possible duplicates, or corrupted state.

This step is non-negotiable. It is what catches the failure modes the live pattern cannot - the rare case where Daraja told both your callback handler and your poller that the transaction completed, but the funds never actually left the customer's wallet (yes, it happens, usually during Safaricom system maintenance).

The idempotency rules that make this safe

Three constraints, enforced in the database, not in application code:

  1. Unique index on (shortcode, mpesa_receipt_number). Once a receipt is recorded, it cannot be recorded again. Any duplicate callback or duplicate poll result that tries to write the same receipt fails fast at the database level.

  2. State transitions via a documented finite state machine. A pending transaction can become completed, failed, timed_out, or unknown. A completed transaction cannot become anything else. A failed transaction cannot become completed. Enforce these in code with explicit checks, not implicit "last writer wins".

  3. Soft-locking the pending row during update. Use SELECT ... FOR UPDATE (Postgres, MySQL) when reading a pending transaction to update its state. The callback handler and the poller both update the same row; without a row lock you will eventually race and both write at once.

If your integration cannot point to these three guarantees, it is one busy Monday morning away from a duplicate-credit incident.

What clean reconciliation produces

A daily reconciliation report should produce four counts:

  • Settled: transactions present in both M-PESA statement and your ledger, with matching amounts. This should be ~99%+ of daily volume.
  • Statement-only: transactions in the statement with no matching ledger entry. Should be 0; if not 0, investigate before close of business.
  • Ledger-only: transactions marked completed in your ledger with no matching statement entry. Should be 0; if not 0, you have a state corruption bug.
  • Mismatched: transactions in both with differing amounts. Should be 0; if not 0, suspect partial refunds or fee deductions you did not model.

A team that sees these four numbers every morning catches problems before customers do. A team that does not is debugging in production.

When you might skip polling

There are two narrow cases where webhook-only is defensible:

  1. You are processing C2B (customer-initiated paybill/till), not STK Push. With C2B, the customer has already pressed pay before your system is involved - there is no "in-flight" state to poll for. Webhook plus daily reconciliation is enough.

  2. You can absorb a minutes-long delay between payment and acknowledgement. Some workflows genuinely do not need real-time feedback - automated subscription renewals, deposits for orders that will not ship for days, donation receipts. If a 5-minute delay is acceptable, the polling job becomes optional.

For online checkout, fee payment, deposit collection, and anything where the customer is sitting at a screen waiting - you need the polling job. Skip it and you will be answering "did my payment go through?" support tickets forever.

The three things to instrument

If you build this pattern, you also need to know it is working:

  1. Webhook arrival latency. p50 and p95 of the time between STK initiation and callback receipt. A p95 climbing above 60 seconds is your early warning that Safaricom is under load.

  2. Polling resolution rate. Of the transactions that polling resolved (not the callback), what percent completed successfully? This is your "how much would webhook-only have lost us today" number. Anything over 1% means webhook delivery is degraded and worth investigating.

  3. Reconciliation drift. The four counts from the daily report. Track them as a time series. Any non-zero statement-only or ledger-only is an incident.

These three metrics will tell you whether the pattern is healthy. They take less than an hour to add to most stacks, and they save days of forensic work the first time something breaks.

What you should not do

Do not skip the polling job because "the callbacks usually work". They usually do. The cases where they do not are exactly the cases that cost you real money - peak Monday morning, school fees week, a Safaricom maintenance window. Build for the bad days, not the good ones.

The TL;DR

  • Webhook is the hint. Use it for fast feedback when it arrives.
  • Transaction Status is the source of truth. Poll it when the callback is late, lost, or duplicated.
  • The statement is the system of record. Reconcile against it daily; investigate any drift before close.
  • Idempotency lives in the database. Unique index on the M-PESA receipt, explicit state machine, row locks on update.
  • Instrument three numbers: callback latency, polling-rescued percentage, daily reconciliation drift.

Get these right and you will run M-PESA at any volume without phantom failures. Get them wrong and you will be the one explaining to a finance manager why the Paybill says one thing and your dashboard says another.

Need this wired in properly?

Our M-PESA Integration & Reconciliation flagship build ships exactly this pattern - hybrid webhook plus polling, database-enforced idempotency, daily reconciliation, and an audit-ready ledger. Standalone, for businesses that already have a website or system but need the payment plumbing done right.

When to call us

If your current integration is webhook-only and you suspect you are losing money you cannot prove, we run a planning call specifically for businesses on Daraja. We will tell you honestly whether the pattern needs a rebuild, an upgrade, or just better instrumentation.

We have audited M-PESA integrations that looked fine on the surface and were silently double-crediting orders. We have shipped reconciliation flows for businesses that had stopped trusting their own ledger. And we have rebuilt the polling layer for teams whose customers were giving up on slow checkouts.

If any of that sounds familiar, book a call. We will be straight with you about what to do.