Designing a mobile-money payment flow that never double-charges

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:

  1. Your server sends a payment request to the provider (Safaricom, Econet, MTN MoMo) with the customer's phone number and amount.
  2. The provider pushes an STK prompt (SIM Toolkit) directly to the customer's phone — a native PIN dialog that appears outside your app entirely.
  3. The customer enters their mobile-money PIN to authorise the payment.
  4. The provider processes the transaction and sends a callback (webhook) to your server with the result — success, failure, or timeout.
  5. 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.

Your App Your Server M-Pesa / EcoCash provider Phone 1. pay request 2. initiate 3. STK push 4. enter PIN 5. callback 6. confirm ⚠ danger zone Connection drops between steps 1–5. App has no idea if payment went through. Client retries → double charge.
The async gap between initiating payment and receiving the callback is where double charges happen.

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:

  1. User taps "Pay $5.00".
  2. App sends the request. The server receives it, calls the M-Pesa API, gets a checkout request ID back. Payment is now in-flight.
  3. The user's connection drops (they walked into a building, the tower switched, 3G hiccupped — it doesn't matter why).
  4. The app gets a network timeout. It has no idea whether the server even received the request, let alone whether M-Pesa processed it.
  5. User sees "Payment failed — try again" and taps retry.
  6. 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

  1. 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.
  2. Scope keys per user. Your unique constraint should be (user_id, idempotency_key), not just idempotency_key alone. Otherwise a leaked or colliding key from one user could block a legitimate payment from another.
  3. 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.

Related
→ How Aviator's provably-fair crash mechanic works → WebSocket vs SSE vs WebTransport: picking the right real-time channel → Tapiwa Makandigona — portfolio & projects
Why do mobile-money payments need idempotency more than card payments?

Mobile-money payments run over cellular networks that drop and reconnect constantly. The flow is asynchronous — you initiate a request, then wait for a callback that may arrive seconds or minutes later. If the user's connection drops mid-request, the client has no way to know whether the payment went through, so it retries. Without idempotency, that retry creates a second charge. Card payments on stable broadband connections have the same theoretical problem, but the failure rate on mobile networks in practice is dramatically higher.

What is an idempotency key in a payment API?

An idempotency key is a unique string the client generates for each logical payment and sends as a header or parameter with every attempt. The server stores this key alongside the transaction. If it sees the same key again, it returns the original result instead of creating a new charge. The key should be a UUID or a deterministic fingerprint of the payment intent — never a timestamp or a sequential counter, because those are too easy to collide or guess.

How do I handle duplicate webhook callbacks from M-Pesa or EcoCash?

Use the provider's transaction ID or checkout request ID as a deduplication key. Before processing any callback, run an INSERT or conditional UPDATE with a unique constraint on that ID. If it conflicts, the callback is a duplicate — return 200 to acknowledge it but skip the business logic. Store the raw callback payload for audit purposes either way. Never rely on your application being called exactly once; mobile-money providers retry callbacks aggressively if they don't get a fast 200 response.