The first time my ride-hailing app charged a passenger twice for the same trip, I got a phone call at 11pm from a very unhappy driver who'd already given change from his own pocket. The passenger's phone had dropped signal mid-payment, the client retried, and my server happily processed both requests as separate transactions. That night I learned the hard way what idempotency means in a mobile-money context — and why the patterns that work for Stripe on stable broadband don't translate directly to M-Pesa, EcoCash, or any other STK-push-style payment API running over 3G.
How a mobile-money payment actually works
If you've only ever integrated Stripe or PayPal, mobile money will feel alien. There's no card number, no client-side token. The flow is push-based and asynchronous:
- Your server sends a payment request to the provider (Safaricom, Econet, MTN MoMo) with the customer's phone number and amount.
- The provider pushes an STK prompt (SIM Toolkit) directly to the customer's phone — a native PIN dialog that appears outside your app entirely.
- The customer enters their mobile-money PIN to authorise the payment.
- The provider processes the transaction and sends a callback (webhook) to your server with the result — success, failure, or timeout.
- Your server updates the order and notifies the client.
The critical thing to understand: steps 2–4 happen outside your control. The STK prompt might take 30 seconds to arrive. The customer might be in an elevator. The callback might come 5 seconds later, or 2 minutes later, or never (timeout). Your app is flying blind between step 1 and step 4, and the user's phone is on a network that drops connections constantly.
The double-charge problem on flaky networks
On stable broadband this flow is mildly annoying. On a 3G network in Harare or Nairobi, it's a minefield. Here's the failure scenario I hit repeatedly:
- User taps "Pay $5.00".
- App sends the request. The server receives it, calls the M-Pesa API, gets a checkout request ID back. Payment is now in-flight.
- The user's connection drops (they walked into a building, the tower switched, 3G hiccupped — it doesn't matter why).
- The app gets a network timeout. It has no idea whether the server even received the request, let alone whether M-Pesa processed it.
- User sees "Payment failed — try again" and taps retry.
- The server sees what looks like a brand new payment request and initiates a second M-Pesa transaction. The customer gets two STK prompts.
Congratulations: you've just charged someone twice for one ride. And unlike a card payment where a chargeback is a click away, clawing back a mobile-money transaction requires a manual reversal through the provider — which can take 24–48 hours.
Idempotency keys: the fix
The solution is a pattern every serious payment API uses, but that you have to implement yourself when you're the one orchestrating mobile-money flows. The idea is simple: every payment attempt carries a unique key, and the server uses that key to guarantee it only processes the payment once.
Here's how I implement it:
// Client-side: generate the key ONCE per logical payment
const idempotencyKey = crypto.randomUUID();
// Every retry sends the same key
async function pay(amount, phone) {
return fetch('/api/pay', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey // same key on every retry
},
body: JSON.stringify({ amount, phone })
});
}
The key is a UUID generated once when the user taps "Pay". It stays the same across every retry — that's the whole point. On the server side:
// Server-side: check before initiating payment
async function handlePayment(req) {
const key = req.headers['idempotency-key'];
const existing = await db.query(
'SELECT * FROM payments WHERE idempotency_key = $1',
[key]
);
if (existing) {
// Already processed — return the original result
return { status: existing.status, txn_id: existing.txn_id };
}
// First time seeing this key — create record and initiate
const payment = await db.query(
`INSERT INTO payments (idempotency_key, amount, phone, status)
VALUES ($1, $2, $3, 'pending')
RETURNING *`,
[key, req.body.amount, req.body.phone]
);
// Now call M-Pesa / EcoCash
const result = await mobileMoneyAPI.initiate(payment);
return result;
}
The critical detail: the INSERT must happen before the mobile-money API call. If you call M-Pesa first and then try to record the idempotency key, a crash between those two steps means the retry won't know the payment already went through. Write first, call second.
Handling webhooks safely
The idempotency key protects the initiation side. But you also need to protect the callback side. Mobile-money providers retry callbacks aggressively — M-Pesa will hit your endpoint multiple times if it doesn't get a fast 200 response. If your callback handler credits the user's account on every hit, one successful payment can trigger multiple credits.
The fix is the same pattern, just with a different key: the provider's own transaction ID.
async function handleCallback(req) {
const txnId = req.body.TransactionID; // M-Pesa's unique ID
// Atomic dedup: try to mark this callback as processed
const result = await db.query(
`UPDATE payments
SET status = 'completed', provider_txn_id = $1
WHERE checkout_request_id = $2
AND status = 'pending'
RETURNING *`,
[txnId, req.body.CheckoutRequestID]
);
if (result.rowCount === 0) {
// Already processed or unknown — acknowledge and move on
return res.status(200).json({ ok: true });
}
// First time: credit the account, notify the user
await creditAccount(result.rows[0]);
return res.status(200).json({ ok: true });
}
The WHERE status = 'pending' clause does the deduplication work. Only one
callback can transition a payment from pending to completed.
Every subsequent callback hits rowCount === 0 and returns 200 without doing
anything. Always return 200 for duplicates — if you return an error, the provider
will keep retrying.
The state machine that holds it together
In production I model each payment as a state machine with four states:
created— idempotency key recorded, no API call yet.pending— mobile-money API called, waiting for callback.completed— callback received, payment confirmed.failed— callback received with a failure, or timed out.
Transitions are strictly one-way: created → pending → completed
or created → pending → failed. A payment can never go backwards. Each
transition is a single SQL update with a WHERE status = <current> guard,
so concurrent requests can't accidentally advance the state twice.
The state machine is the contract between your initiation logic and your callback handler. The idempotency key gates entry; the state transitions gate every step after that. Together they form a pipeline that processes each payment exactly once, no matter how many times the network retries, the provider retries, or the user taps the button.
Timeout handling: the hardest edge case
M-Pesa STK pushes typically time out after 30–60 seconds if the user doesn't enter their PIN. EcoCash is similar. But "timed out" doesn't always mean "didn't happen" — sometimes the user entered the PIN at the last second and the callback arrives 90 seconds later, or after your own timeout window has closed.
My approach: run a background job that polls for pending payments older than
3 minutes. For each one, query the provider's transaction-status API (M-Pesa's
Query endpoint, MTN MoMo's GET /requesttopay/{referenceId})
to check whether it actually went through. Only transition to failed if
the provider confirms it didn't process. This catches the late-arriving-success case
that would otherwise leave money in limbo.
Three rules I learned the hard way
- Generate the idempotency key on the client, not the server. If the server generates it, a dropped response means the client never receives the key and can't use it to retry safely. The client must own the key from the start.
- Scope keys per user. Your unique constraint should be
(user_id, idempotency_key), not justidempotency_keyalone. Otherwise a leaked or colliding key from one user could block a legitimate payment from another. - Expire old keys. A 24-hour TTL is reasonable — long enough for any retry storm to resolve, short enough that the user can intentionally make the same payment tomorrow without hitting a stale key. Run a daily cleanup job to purge expired entries.
Mobile-money payment flows aren't harder than card-payment flows in theory. They're harder in practice because the network assumptions that card-payment infrastructure relies on — low latency, stable connections, synchronous confirmation — simply don't hold on a 3G network in sub-Saharan Africa. Idempotency isn't a nice-to-have here. It's the difference between an app people trust with their money and one that gets uninstalled after the first double charge.