Skip to content

Idempotency keys: how to stop double charges on M-Pesa and card payments

Why retrying a payment request can charge a customer twice, how idempotency keys prevent it, how to choose a good key, and what to do when the outcome of a payment is unknown.

· 7 min read · Noria Technologies

A customer taps Pay. Your server sends the request to the payment provider. The network drops before the answer comes back. Did the payment go through?

You do not know. If you send the request again and the first one succeeded, the customer is charged twice. If you do not send it again and the first one failed, you lose the sale. Idempotency keys are how payment APIs get you out of this trap.

What an idempotency key is

An idempotency key is a unique value you send with a request that moves money. The server remembers the key and the result. If the same key arrives again, the server returns the original result instead of doing the work a second time.

That turns “send it again” from dangerous into safe. A retry after a timeout either returns the payment that already happened or completes the one that did not, never both.

curl https://pay.noria.co.ke/v1/charges \
  -H "Authorization: Bearer $NORIA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: invoice-1042" \
  -d '{
    "amount_minor": 250000,
    "currency": "KES",
    "channel": "mpesa",
    "payer_phone": "254712345678",
    "reference": "INV-1042",
    "description": "BillGrid invoice INV-1042"
  }'

Send that twice and you get the same charge back twice. The customer sees one prompt.

Choosing a good key

A good key identifies one intention to pay, not one HTTP request.

  • Derive it from your own record. invoice-1042, sitebooks-wages-2207 or payout-2207 ties the key to the thing being paid for. If your code retries, crashes and restarts, or a user double-clicks, the key is the same.
  • Make it unique per attempt you mean. If a customer genuinely wants to pay the same invoice again after a failed payment, use a new key, such as invoice-1042-attempt-2.
  • Do not use random values generated per request. A fresh UUID on every retry defeats the purpose, because each retry looks like a new payment.
  • Store it before you send. Save the key with your order first, so a restart uses the same one.

Same key, different request

What should happen if the same key arrives with a different amount? It is almost always a bug, such as two orders accidentally sharing a key. A safe API refuses rather than guessing which one you meant.

Noria Pay stores a fingerprint of each request body with its key. The same key with the same body replays the original response. The same key with a different body is refused with 409 idempotency_mismatch, and nothing is sent to the provider.

Timeouts mean “unknown”, not “failed”

Idempotency keys make retries safe, but they do not answer the real question after a timeout: did the money move?

A payment whose outcome is unknown must be resolved by asking the provider, not by starting a new payment. The rules that keep you safe:

  1. Retry with the same key. That is always safe: it can never dispatch a second payment.
  2. Never start a new payment with a new key while the old one is unknown. That is exactly how double charges happen.
  3. Ask the provider. Use the provider’s status query, or let your payments layer do it.

Noria Pay enforces the second rule for you. A new charge for the same reference, payer, amount and channel within a short window reuses the charge that is already open instead of prompting the customer again. While that charge’s outcome is unknown, Pay answers 409 outcome_unknown with its transaction_id rather than dispatching anything, whether you retried with the same key or a new one. Pay keeps querying the provider in the background, and the outcome arrives as a signed webhook.

Webhooks need idempotency too

The same problem runs in the other direction. Payment webhooks are delivered at least once, so your server will sometimes receive the same event twice, or a newer event before an older one.

  • Deduplicate by event ID. Noria Pay sends each event’s ID in the Pay-Event-Id header and the body. Store it and ignore repeats.
  • Order by sequence. Each event carries a sequence that increases per transaction. Keep the newest and ignore older ones that arrive late.
  • Credit by transaction ID. Apply the payment to your order once per transaction_id, so a later event that only adds a receipt does not credit the order again.
async function handlePayEvent(event) {
  const seen = await db.events.insertIfAbsent(event.id);
  if (!seen.inserted) return;

  const order = await db.orders.byReference(event.data.reference);
  if (event.sequence <= order.lastPaySequence) return;

  await db.orders.update(order.id, {
    lastPaySequence: event.sequence,
    status: event.data.status,
    paidTransactionId: event.data.status === 'succeeded' ? event.data.transaction_id : order.paidTransactionId,
  });
}

Verify the signature before any of this; the guide on verifying webhook signatures shows how.

Checklist

  • Every request that moves money carries an idempotency key.
  • Keys come from your own records and are saved before the request is sent.
  • Retries reuse the key; genuinely new attempts get a new one.
  • A timeout is treated as unknown and resolved by asking the provider.
  • Webhook handlers deduplicate by event ID and ignore older sequences.

See how this fits the rest of the API on the developers page.

Ready to take payments properly?

Tell us what you are building and which providers you use. We set up your workspace and help you go live.