Why Payment Retries Need Idempotency

Naseebullah Ahmadi  Senior Software Engineer, London

A plain payment endpoint looks correct until you trace what a double-click, a timed-out request, or a redelivered webhook actually does to it. Each one turns one payment into two. Idempotency keys are the fix, at two layers most write-ups skip.

17 min read
#engineering
In one line

A payment endpoint with no idempotency looks correct in every manual test and still double-charges in production, because a double-click, a timed-out request, and a redelivered webhook all replay the exact same call. A client-generated idempotency key, forwarded to the payment provider's own idempotency mechanism, closes all three at once. The part that still needs care after that is the call that times out with a genuinely unknown outcome.

Payments are the one place where "just retry it" can cost someone money. Everywhere else, a duplicate write is a bug. A duplicate charge is a support ticket, a chargeback, and a customer who doesn't trust you with their card again.

That asymmetry is why it's worth walking through what actually goes wrong before reaching for the fix.

You've already felt the underlying problem, just not in code. A card machine freezes for a few seconds after you tap, so you tap again. Either it just charged you twice, or the first tap never went through and you're about to walk out with a free coffee. That few seconds of not knowing is the whole problem this post is about. It's just moved from a card reader to an HTTP request.

Start with the version that looks fine

Here's a payment endpoint that passes every manual test you'd think to run. It validates the amount, calls the provider, records the result.

@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 this works. Nothing here looks wrong. That's exactly the problem: every scenario that breaks it looks identical to the client and identical in the code path taken.

One thing this endpoint gets right for free: it only ever sees an amount. The card number itself never reaches this server. Stripe's own hosted fields collect it directly, which is what keeps this code out of PCI DSS scope in the first place. Everything below is about the charge, never about handling a card number safely, because this server never does.

The double-click

The user clicks Pay, the network feels slow, they click it again. Two requests reach createPayment a few hundred milliseconds apart: a textbook race condition. Nothing in the function above knows it's happening. Both run to completion:

The retry after a timeout

The user clicks Pay once. The request reaches the server, the card gets charged, and the response is lost somewhere on the way back (a proxy timeout, a dropped connection, a phone losing signal). The client sees nothing, waits, and retries, because from where it's standing that's the only correct thing to do. It never got an answer.

The webhook, redelivered

Say this endpoint also has a webhook handler that grants order access once Stripe confirms the charge:

@itsnas TypeScript
// POST /webhooks/stripe - no dedup
async function handleWebhook(req: Request) {
  const event = stripe.webhooks.constructEvent(
    req.rawBody,
    req.header('stripe-signature'),
    WEBHOOK_SECRET,
  )
 
  if (event.type === 'payment_intent.succeeded') {
    await grantOrderAccess(event.data.object.id)
  }
 
  return json(200, { ok: true })
}
main
Nas (@itsnas)

Providers redeliver a webhook event if your server is slow to acknowledge it, or on their own transient failures. That's a documented part of the contract, not a bug on their end.

Three different triggers (a person, a network, a delivery guarantee) collapse to the same shape: the same logical operation runs the function body more than once. None of them are exotic. They're the default behavior of clients, networks, and webhook providers.

Disabling the button isn't the fix

The obvious instinct is to disable the Pay button after the first click. It's good UX and worth doing, but notice it only touches the first trace above. It does nothing for a reload, a second tab, or the client's own timeout retry. And it does nothing at all for the webhook. The fix has to live where all three traces actually collide: the server.

Fixing it: idempotency keys, at two layers

Idempotent just means: do it once, or do it five times, the outcome is the same. That's the one property missing from every trace above. And the fix borrows something you already use for other things: a receipt number. Book a flight twice with the same confirmation code and the airline doesn't sell you two seats. It just shows you the same booking again.

The client generates one such key per checkout attempt (when the form is shown, not per HTTP request) and sends it on every attempt of that payment, including the double-click and the retry. The server claims that key once and treats every later attempt as a replay rather than a new request.

That alone would stop the first two traces. The reason it needs a second layer is scenario two: if the process crashes after calling Stripe but before recording the result, the server's own claim on the key doesn't tell it what Stripe actually did. So the same key also has to be forwarded as #stripe's own Idempotency-Key, which is the layer most write-ups skip:

@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)

Re-run the double-click trace against this version: request A claims the key and proceeds; request B's insert conflicts, finds A's row, and replays its status instead of calling Stripe a second time. Re-run the timeout retry: the retry carries the same key as the original attempt, so it's the same replay path, not a fresh charge.

The key is a dedupe token, not a password

One assumption is doing a lot of work above: account_id comes from the authenticated session, never from anything the client sends. Take it from the request body instead and the whole scheme stops meaning anything. An attacker who can also choose which account they're claiming a key against can collide with, or read the replayed result of, someone else's payment.

The sharper gap is in the code itself. Nothing above checks that a replayed key belongs to the same request. Reuse K with a different amount and the handler happily returns the first payment's stored status without ever charging the new one:

The fix is to check the stored request actually matches before replaying, and refuse the replay outright if it doesn't:

@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

This is exactly what Stripe's own idempotent-request handling does: reuse one of their idempotency keys with different parameters and you get an error back, not a silent replay of the old response. A key proves "you already asked me this," nothing more, so treat any mismatch as a hostile or buggy caller, never as a shortcut.

The charge that times out

This is the trace the two fixes above don't fully close, and it's the one that actually breaks systems in production: the call to Stripe itself times out. Not the response to the client, the call to the provider. You don't get succeeded, you don't get failed, you get nothing. It's the frozen card machine from the start of this post, just moved from the counter to a network call. And this time it's your server standing there not knowing either.

Both instincts here are wrong. Assume it failed and let something retry the charge without the same key reaching Stripe, and a succeeded charge becomes two. Assume it succeeded and mark the order paid, and a failed charge means you've shipped a product you were never paid for.

The only correct answer is to not guess. The payment sits in processing, and you resolve it by asking the one source of truth that actually knows: the provider, either by polling the PaymentIntent status or by waiting for its webhook. A timeout on your side is not evidence of anything on their side.

  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 isn't a dead end, it's a poll target. Keep the Pay button disabled, show a waiting state, and check the payment's status every couple of seconds until it leaves processing. Never re-POST just because the wait feels long. That's the double-click from the start of this post, wearing a patience costume.

Which puts the webhook handler back in the frame, and it needs the same fix as the payment endpoint: dedupe by the event's own ID before acting on it.

@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)

Re-run the webhook trace against this version: the redelivered event's own insert conflicts on event_id, so grantOrderAccess runs exactly once no matter how many times Stripe resends it. And a declined card now actually reaches failed instead of sitting in processing forever, which the naive version never handled either.

Reconciliation is the seatbelt, not the fix

Even with both layers of idempotency key and a webhook handler that dedupes correctly, run a periodic job that pulls the provider's transaction list for the last few hours and diffs it against your own payments table. Every real payment system has one. It exists for the case the code above doesn't cover: a bug, a manual database fix, a webhook that never arrived and nobody polled for. It should page a human on a mismatch, not silently correct it. Money moving without a person noticing is its own risk.

  1. 1

    Keying on the amount and card instead of a client-generated key

    A customer buying the same item twice in a row is not a duplicate. The key has to be an explicit per-attempt identifier the client owns, not something derived from the payload.

  2. 2

    Stopping at your own idempotency table

    Dedupes the request, but a crash between calling the provider and recording the result still leaves the provider call unprotected. Forward the same key as their idempotency header.

  3. 3

    Treating a client-side timeout as a failure

    The request may have succeeded on the server before the response was lost. Resolve processing by asking the provider, never by what the client's own network call returned.

  4. 4

    Trusting a webhook to arrive exactly once

    Providers redeliver on their own retries. Dedupe by the event ID with the same unique-constraint pattern as the payment itself.

  5. 5

    Replaying a key without checking the request matches

    A key only proves "you already asked me this." Reuse it with a different amount and a naive replay hands back the old, smaller charge's succeeded status for the new, larger request. Compare the stored request before replaying, and reject the mismatch.

  6. 6

    Scoping the key by anything the client can set

    account_id has to come from the authenticated session. Accept it from the request body and an attacker can claim a key against someone else's account.

The frozen card machine from the start of this post never goes away. Someone somewhere is always going to tap twice or lose a connection at the wrong moment. What changes is that the system behind it always knows what actually happened, even when the person standing at the counter doesn't: an idempotency key stops the double-click and the timeout retry outright, forwarding it to the provider survives a crash mid-call, and asking the provider instead of guessing settles the one trace that survives both.


End of entry · Keep exploring

What's next in the notebook?

Keep reading — more from where that came from.

Featured next
11 min read
0%

Your Logging Sucks!

A checkout endpoint with a log line at every step looks like good observability, right up until a customer says "my payment failed" and you have thirteen unrelated lines from thirteen unrelated requests to sort through. The fix isn't more logs, it's one wide event per request instead.

16 min read
#engineering

What Breaks From 1k to 1M Requests Per Second

The same endpoint, run through four traffic tiers. At 1k req/s almost any design survives. At 10k the database and the single instance give first. At 100k the cache and the load balancer become the systems under test. At 1M the architecture itself has to change, because the failure mode is no longer capacity, it's correlated behavior across clients you don't control.

0%
12 min read
#engineering

Migrating Schema-Per-Tenant Databases at Scale

Choosing physical tenant isolation over a shared, RLS-scoped schema buys two new problems: knowing where a tenant's data actually lives, and running one migration correctly hundreds of times instead of once. Neither has an app-code fix, both need their own infrastructure.

0%
21 min read
#engineering

Designing Multi-Tenant APIs That Scale

A missing tenant filter is a data leak, not a crash. Row-level security fixes that structurally, but rate limits, connection pools, and error codes built for one instance break the same quiet way once the API runs as several.

0%
8 min read
#engineering, #frontend

Building a Typed Fetch Factory

How a single createFetcher factory infers request/response types from an OpenAPI schema and layers in caching, retries, and cancellation, and why each piece is built the way it is.

0%
7 min read
#algorithms

Two Pointers

Two indices walking through one ordered structure, discarding the side that cannot improve the answer at every step and replacing a nested loop with a single pass.

0%
2 min read
#engineering

AI Without Losing Judgment

AI can speed up delivery, but engineers still own architecture, quality, and decisions. A simple workflow to ship faster without outsourcing judgment.

0%