Skip to content

Why Payment Retries Need Idempotency Keys

Naseebullah AhmadiSenior Software Engineer, London

You tap your card, the machine freezes, you tap again. Were you charged twice? An HTTP payment endpoint faces the same question on every retry, and without an idempotency key it gets it wrong.

11 min read
#engineering

Here's an endpoint that passes every manual test you'd think to run:

@itsnas TypeScript
// POST /payments - no idempotency handling at all
async function createPayment(req: Request) {
  const intent = await stripe.paymentIntents.create({
    amount: req.body.amount,
    currency: 'usd',
    confirm: true,
  })
 
  await db`
    INSERT INTO payments (account_id, provider_intent_id, amount, status)
    VALUES (${req.accountId}, ${intent.id}, ${req.body.amount}, ${intent.status})
  `
 
  return json(200, { paymentId: intent.id, status: intent.status })
}
main
Nas (@itsnas)
Passes every manual test, double-charges in production

Click Pay once on a good connection and it works. (The card number never reaches this server; Stripe's hosted fields collect it, which keeps this code out of PCI DSS scope. Everything below is about the charge, not the card.)

One payment, charged twice

The client clicks Pay once. The charge succeeds, and the response gets lost on the way back: a proxy timeout, a phone dropping signal.

  1. Client to API: POST /payments
  2. API to Provider: charge $20
  3. Provider to API: succeeded
  4. API to Client: connection drops
  5. Client to API: POST /payments (retry)
  6. API to Provider: charge $20 again
  7. Provider to API: succeeded
The client never got an answer, so retrying is the only correct thing it can do. The server has no memory of the first charge.

Every line of code did what it was written to do, and the card was charged twice. The same shape shows up from three directions:

TriggerWho repeats the callResult
Double-clickthe usertwo charges
Retry after a timeoutthe client, correctlytwo charges
Webhook redeliveredthe provider, by designorder fulfilled twice

None of these are exotic. They're the default behaviour of people, networks and webhook providers. Disabling the Pay button helps with the first row only; it does nothing for a second tab, the client's own retry, or the webhook. The fix has to live on the server, where all three collide.

The fix: a receipt number, at two layers

Idempotent means doing it once or five times has the same outcome. Think of a booking reference: book a flight twice with the same one and the airline shows you the same booking, it doesn't sell you two seats.

The client generates one key per checkout (when the form is shown, not per HTTP request) and sends it on every attempt. The server claims it once with a unique constraint; any later attempt with the same key is a replay, not a new payment. That's layer one.

Layer two is the one most write-ups skip. If the process crashes after calling Stripe but before saving the result, the server's claim on the key can't say what Stripe did. So the same key is forwarded as #stripe's own Idempotency-Key, and Stripe replays its stored response instead of charging again.

@itsnas TypeScript
// POST /payments - idempotent
async function createPayment(req: Request) {
  const key = req.header('Idempotency-Key')
  if (!key) return json(400, { error: 'Idempotency-Key required' })
 
  return tx(async db => {
    const [claimed] = await db`
      INSERT INTO payments (account_id, idempotency_key, amount, status)
      VALUES (${req.accountId}, ${key}, ${req.body.amount}, 'processing')
      ON CONFLICT (account_id, idempotency_key) DO NOTHING
      RETURNING id
    `
 
    if (!claimed) {
      const [prior] = await db`
        SELECT id, status FROM payments
        WHERE account_id = ${req.accountId} AND idempotency_key = ${key}
      `
      // `processing` here means a concurrent attempt is still in
      // flight, not that this one failed - see "the charge that times
      // out" below.
      return json(200, { paymentId: prior.id, status: prior.status })
    }
 
    // The same key goes to Stripe, so a crash right after this call
    // still resolves safely on the next retry - Stripe replays its own
    // stored response instead of charging again.
    const intent = await stripe.paymentIntents.create(
      { amount: req.body.amount, currency: 'usd', confirm: true },
      { idempotencyKey: key },
    )
 
    await db`
      UPDATE payments
      SET status = ${intent.status}, provider_intent_id = ${intent.id}
      WHERE id = ${claimed.id}
    `
 
    return json(200, { paymentId: claimed.id, status: intent.status })
  })
}
main
Nas (@itsnas)
@itsnas SQL
CREATE TABLE payments (
  id                  uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  account_id          uuid NOT NULL,
  idempotency_key     text NOT NULL,
  amount              integer NOT NULL,
  status              text NOT NULL, -- processing | succeeded | failed
  provider_intent_id  text,
  created_at          timestamptz NOT NULL DEFAULT now(),
 
  UNIQUE (account_id, idempotency_key)
);
main
Nas (@itsnas)

Replay the diagram above against this version: the retry carries the same key, its insert conflicts, and it returns the first attempt's status without calling Stripe.

A key proves "you asked this already", nothing more

Nothing above checks that a replay is the same request. Pay one dollar with key K, then send K again for a thousand: the insert conflicts, the handler finds K succeeded, and returns succeeded for a thousand-dollar order that was charged one. Compare before replaying:

@itsnas TypeScript
if (!claimed) {
  const [prior] = await db`
    SELECT id, status, amount FROM payments
    WHERE account_id = ${req.accountId} AND idempotency_key = ${key}
  `
 
  if (prior.amount !== req.body.amount) {
    return json(422, {
      error: 'Idempotency-Key already used for a different request',
    })
  }
 
  return json(200, { paymentId: prior.id, status: prior.status })
}
main
Nas (@itsnas)
Same key, different amount: refuse the replay instead of trusting it

Stripe's own idempotency does the same: reuse a key with different parameters and you get an error, not the old response. And account_id must come from the authenticated session, never the request body, or a caller can claim keys against someone else's account.

When the charge itself times out

The case keys can't close alone: your call to Stripe times out. No succeeded, no failed, nothing. It's the frozen card machine again, and this time your server is the one standing at the counter.

Both guesses are wrong. Assume it failed and retry without the same key, and a success becomes two charges. Assume it succeeded and ship, and a decline becomes free stock. Don't guess: leave the payment in processing and ask the one party that knows.

  1. Client to API: POST /payments (Key: K)
  2. API to Provider: charge (idempotencyKey: K)
  3. Provider to API: 200 succeeded
  4. API to Client: connection drops
  5. Client to API: retry (same Key: K)
  6. API to Provider: poll status (Key: K)
  7. Provider to API: succeeded
  8. API to Client: 200 succeeded
The charge succeeds at the provider; the response to the API is lost; the retry resolves by asking the provider again, not by guessing.

On the client, processing is a poll target, not a dead end: keep the button disabled, show a waiting state, and check the status every few seconds. Re-POSTing because the wait feels long is the double-click again, wearing a patience costume.

The webhook needs its own key

Providers redeliver events when you're slow to acknowledge. The event's own ID is the key, with the same unique-constraint trick:

@itsnas TypeScript
// POST /webhooks/stripe - idempotent
async function handleWebhook(req: Request) {
  const event = stripe.webhooks.constructEvent(
    req.rawBody,
    req.header('stripe-signature'),
    WEBHOOK_SECRET,
  )
 
  const [claimed] = await db`
    INSERT INTO processed_webhook_events (event_id)
    VALUES (${event.id})
    ON CONFLICT (event_id) DO NOTHING
    RETURNING event_id
  `
  if (!claimed) return json(200, { ok: true }) // already handled
 
  if (event.type === 'payment_intent.succeeded') {
    await db`
      UPDATE payments SET status = 'succeeded'
      WHERE provider_intent_id = ${event.data.object.id}
    `
    await grantOrderAccess(event.data.object.id)
  }
 
  if (event.type === 'payment_intent.payment_failed') {
    await db`
      UPDATE payments SET status = 'failed'
      WHERE provider_intent_id = ${event.data.object.id}
    `
  }
 
  return json(200, { ok: true })
}
main
Nas (@itsnas)

A redelivered event conflicts on event_id, so grantOrderAccess runs once however many times it arrives, and a declined card now actually reaches failed instead of sitting in processing forever.

Knowing what happened

Even with every layer above, run reconciliation against the provider's report: it catches whatever slipped past them.

Someone will always tap twice or lose signal at the wrong moment. The goal isn't to stop that; it's that your system always knows what actually happened, even when the person at the counter doesn't.