Idempotency Keys for Safe Retries
24 Feb 2026A 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.