Advanced HTTP Patterns · Lesson 21 of 30
Idempotency Keys: Safe Retries for POST Requests
A customer taps "Pay ₹2,499". The request reaches your server, the payment succeeds – and the mobile network drops before the response arrives. The app can't tell whether the payment happened. If it retries, the customer may be charged twice; if it doesn't, the order may never be confirmed. Idempotency keys, popularised by Stripe and now an IETF draft standard, make POST requests safe to retry.
In this lesson
- Why retries create duplicates
- The Idempotency-Key header
- An implementation that replays stored responses
- Rules: key reuse, concurrency, expiry
How an idempotency key works
- Clientgenerates a unique key (UUID) for this one logical operation
- First requestserver processes it and stores key → response
- Retry, same keyserver returns the stored response; nothing is charged again
- Same key, different body422 – a key can't be reused for another operation
Implementation
// server.js
import express from 'express';
import { createHash } from 'node:crypto';
const app = express();
app.use(express.json());
const payments = [];
const idempotency = new Map(); // key → { fingerprint, status, body } (Redis/DB with a 24 h TTL in production)
const fingerprint = (req) => createHash('sha256').update(JSON.stringify(req.body)).digest('hex');
app.post('/api/payments', (req, res) => {
const key = req.get('Idempotency-Key');
if (!key) return res.status(400).json({ error: 'Idempotency-Key header is required for payments' });
const seen = idempotency.get(key);
if (seen) {
if (seen.fingerprint !== fingerprint(req)) {
return res.status(422).json({ error: 'This Idempotency-Key was already used with a different request' });
}
return res.status(seen.status).set('Idempotency-Replayed', 'true').json(seen.body); // replay, don't redo
}
// … charge the card exactly once …
const payment = { id: `pay_${payments.length + 1}`, amount: req.body.amount, currency: req.body.currency, status: 'succeeded' };
payments.push(payment);
idempotency.set(key, { fingerprint: fingerprint(req), status: 201, body: payment });
res.status(201).json(payment);
});
app.get('/api/payments', (req, res) => res.json({ count: payments.length, payments }));
app.listen(process.env.PORT ?? 3000);
$ curl -X POST http://localhost:3000/api/payments -H 'Content-Type: application/json' -H 'Idempotency-Key: 7f9c2b1e-checkout-1001' -d '{"amount":249900,"currency":"INR"}'
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
{
"id": "pay_1",
"amount": 249900,
"currency": "INR",
"status": "succeeded"
}
$ curl -X POST http://localhost:3000/api/payments -H 'Content-Type: application/json' -H 'Idempotency-Key: 7f9c2b1e-checkout-1001' -d '{"amount":249900,"currency":"INR"}'
HTTP/1.1 201 Created
Idempotency-Replayed: true
Content-Type: application/json; charset=utf-8
{
"id": "pay_1",
"amount": 249900,
"currency": "INR",
"status": "succeeded"
}
$ curl -X POST http://localhost:3000/api/payments -H 'Content-Type: application/json' -H 'Idempotency-Key: 7f9c2b1e-checkout-1001' -d '{"amount":999,"currency":"INR"}'
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json; charset=utf-8
{
"error": "This Idempotency-Key was already used with a different request"
}
$ curl http://localhost:3000/api/payments
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{
"count": 1,
"payments": [
{
"id": "pay_1",
"amount": 249900,
"currency": "INR",
"status": "succeeded"
}
]
}The retry got exactly the same 201 response – with Idempotency-Replayed: true – and the payment list still has one payment. Reusing the key for a different amount was rejected instead of silently returning the old payment.
The client side
import { randomUUID } from 'node:crypto'; // or crypto.randomUUID() in browsers
async function pay(amount) {
const key = randomUUID(); // ONE key per logical payment, reused for every retry
for (let attempt = 1; attempt <= 3; attempt++) {
try {
const res = await fetch('/api/payments', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': key },
body: JSON.stringify({ amount, currency: 'INR' }),
signal: AbortSignal.timeout(5000),
});
if (res.status < 500) return res.json(); // success or a client error – don't retry 4xx
} catch { /* timeout or network error: retry with the same key */ }
}
throw new Error('Payment status unknown – check later');
}
Production rules
- Atomic first write. Two retries can arrive at the same moment. Insert the key with status "processing" using a unique constraint before doing the work; a second request with the same key gets
409 Conflictuntil the first finishes. - Scope keys to the authenticated account so one customer's key can't collide with another's.
- Store the full response (status and body), and expire keys after 24 hours or so.
- Fingerprint the request and reject reuse with a different body.
- Use idempotency keys for payments, orders, transfers, emails and any POST with side effects that clients might retry.
Practice
Try it yourself
Why must the client reuse the same key for retries but generate a new key when the user clicks "Pay" again for a second purchase?
Show answer
The key identifies one logical operation. Retries of that operation must share it so the server can recognise duplicates; a genuinely new purchase is a different operation and needs a different key, or it would be treated as a replay of the first.
Quick check
Which methods need idempotency keys?
Non-idempotent ones with side effects – mainly POST (and sometimes PATCH).
What does the server return for a retried key?
The stored original response.
What if the same key arrives with a different body?
Reject it (422), never replay or reprocess.
Summary
- Retries after timeouts can duplicate POST side effects like payments.
- An Idempotency-Key lets the server process once and replay the stored response for retries.
- Store keys atomically per account, fingerprint requests, and expire keys after a day.
Finished this lesson?
Mark it complete to track your progress, then carry on with the next lesson.