IAnswerable

Idempotency Keys for Safe Retries

24 Feb 2026

A payment request times out. The client, doing the reasonable thing, retries. Your server, having already charged the card once, charges it again. This is the single most common way small products double-charge customers, and idempotency keys are the fix.

The mechanism

The client generates a unique key per logical operation — not per HTTP request, per intent — and sends it with every attempt:

POST /charges
Idempotency-Key: 8f14e45f-ceea-467e-b1cc-3c1e3c1e3c1e

The server checks if it’s seen that key before. If yes, it returns the original response without touching the payment processor again. If no, it processes the charge and stores the result against that key. Here’s the full round trip:

sequenceDiagram
    participant Client
    participant API
    participant Processor
    Client->>API: POST /charges (Idempotency-Key)
    alt Key seen before
        API-->>Client: Return stored response
    else New key
        API->>API: Store key, mark processing
        API->>Processor: Create charge
        Processor-->>API: Charge result
        API->>API: Store result against key
        API-->>Client: Return response
    end
  
app.post('/charges', async (req, res) => {
  const key = req.headers['idempotency-key']
  if (!key) return res.status(400).json({ error: 'Idempotency-Key required' })

  const existing = await db.idempotencyKeys.findUnique({ where: { key } })
  if (existing) {
    return res.status(existing.statusCode).json(existing.response)
  }

  // reserve the key before doing the work, so a concurrent retry doesn't race past here
  await db.idempotencyKeys.create({ data: { key, status: 'processing' } })

  try {
    const charge = await stripe.charges.create({ amount: req.body.amount, source: req.body.source })
    await db.idempotencyKeys.update({
      where: { key },
      data: { status: 'complete', statusCode: 200, response: charge },
    })
    res.status(200).json(charge)
  } catch (err) {
    await db.idempotencyKeys.update({ where: { key }, data: { status: 'failed' } })
    res.status(500).json({ error: err.message })
  }
})

The race condition this doesn’t fix by itself

Two retries can arrive close enough together that both pass the “have I seen this key” check before either one writes the “processing” row. Fix it with a unique constraint on the key column, not application logic:

CREATE TABLE idempotency_keys (
  key TEXT PRIMARY KEY,
  status TEXT NOT NULL,
  response JSONB,
  created_at TIMESTAMPTZ DEFAULT now()
);

The database, not your code, is what makes two concurrent inserts with the same key impossible. Catch the unique-violation error and treat it the same as “existing key found” — poll or return a 409 telling the client to retry the fetch.

Where the key comes from

The client generates it, not the server — that’s the whole point. If the server generated it, a timeout before the response reaches the client means the client never learns the key and can’t retry safely. A client-generated UUID, stored locally before the request goes out, survives the request failing entirely.

// client side, generate once, reuse on every retry of the same logical charge
const idempotencyKey = crypto.randomUUID()

What still needs a TTL

Don’t keep idempotency keys forever. A card charge retried six months later with a stale key is a bug, not a legitimate retry. Expire keys after 24 hours — long enough to cover any realistic retry window, short enough that the table doesn’t grow unbounded:

DELETE FROM idempotency_keys WHERE created_at < now() - interval '24 hours';

Run it as a daily cron job, not on every request.

The one thing to not skip

It’s tempting to add idempotency keys “later, once it’s actually a problem.” The problem, when it happens, is a customer emailing support about a duplicate charge — and by then it’s happened to more than one customer, because the retry conditions that trigger it (slow networks, flaky mobile connections) aren’t rare. Add the key before you take a card number, not after the first support ticket.