Idempotency Keys in REST APIs
A client sends a payment request, the network times out before the response comes back, and the client — reasonably — retries. If your POST /payments endpoint just runs the charge again, the customer gets billed twice. Idempotency keys solve exactly this problem: they let a client safely retry a request that may or may not have succeeded, without risking a duplicate side effect.
The core idea
An idempotency key is a unique value the client generates once per logical operation (usually a UUID) and sends on every attempt of that operation, including retries.
POST /payments
Idempotency-Key: 8f14e45f-ceea-4f3f-9d5f-3b6b1a2c9a10
{ "amount": 4999, "currency": "usd", "customerId": "cus_123" }
The server's job is simple in principle: the first time it sees a given key, it performs the operation and stores the result keyed by that value. Every subsequent request with the same key returns the stored result instead of running the operation again — even if the request body looks identical, even if it arrives seconds or hours later.
A minimal implementation
Here's the shape of it against a Node/Express handler backed by a database table:
CREATE TABLE idempotency_keys (
key TEXT PRIMARY KEY,
request_hash TEXT NOT NULL,
status TEXT NOT NULL, -- 'in_progress' | 'completed'
response_body JSONB,
response_status INT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
async function handlePayment(req, res) {
const key = req.header('Idempotency-Key');
if (!key) return res.status(400).json({ error: 'Idempotency-Key header required' });
const requestHash = hashBody(req.body);
const existing = await db.query('SELECT * FROM idempotency_keys WHERE key = $1', [key]);
if (existing.rows.length) {
const record = existing.rows[0];
if (record.request_hash !== requestHash) {
return res.status(422).json({ error: 'Idempotency key reused with a different request body' });
}
if (record.status === 'completed') {
return res.status(record.response_status).json(record.response_body);
}
return res.status(409).json({ error: 'Request with this key is still processing' });
}
await db.query(
'INSERT INTO idempotency_keys (key, request_hash, status) VALUES ($1, $2, $3)',
[key, requestHash, 'in_progress'],
);
const result = await chargeCustomer(req.body);
await db.query(
'UPDATE idempotency_keys SET status = $1, response_body = $2, response_status = $3 WHERE key = $4',
['completed', result.body, result.status, key],
);
return res.status(result.status).json(result.body);
}
The request_hash check matters: it stops a client from accidentally (or maliciously) reusing a key for a genuinely different operation. If the body doesn't match what was stored for that key, reject it rather than silently running the new request or returning the old response.
The race condition you have to handle
The naive version above has a gap: two requests with the same key can both pass the "does it exist?" check before either one inserts a row, and both end up charging the customer. Fix it by making the insert itself the point of atomicity — use a unique constraint on key and let the database reject the second insert:
try {
await db.query(
'INSERT INTO idempotency_keys (key, request_hash, status) VALUES ($1, $2, $3)',
[key, requestHash, 'in_progress'],
);
} catch (err) {
if (err.code === '23505') { // unique_violation
// Someone else beat us to it — poll or return 409, don't proceed.
return res.status(409).json({ error: 'Request with this key is already being processed' });
}
throw err;
}
This is the whole reason idempotency keys need a database (or equivalent) in the first place — an in-memory Map on a single server instance won't save you once you have more than one server behind a load balancer, and it definitely won't survive a restart mid-request.
What counts as "the operation"
Idempotency keys protect against retries of the same request, not against the client calling the endpoint twice on purpose with different keys. That distinction is the client's responsibility: generate one key per logical action (per checkout attempt, per form submission), not one key per HTTP call the client happens to make, and not a fixed key reused across unrelated operations.
A common mistake is generating a fresh UUID on every retry instead of reusing the one from the first attempt — that defeats the entire mechanism, because the server sees each retry as a brand-new key and processes it fresh. The key has to be generated once, before the first attempt, and carried through every retry of that same attempt.
Expiring old keys
You don't want this table to grow forever. Idempotency keys are only useful for the retry window a client might realistically use — a background job or cron can safely delete rows older than, say, 24 hours:
DELETE FROM idempotency_keys WHERE created_at < now() - interval '24 hours';
If a client somehow retries after that window with the same key, worst case they get a genuine duplicate operation — an acceptable trade-off, since a retry a day later almost certainly isn't the same user action anymore.
Where this actually belongs
Not every endpoint needs this. GET requests are idempotent by definition — running them twice has no side effects, so there's nothing to protect. The technique matters for POST and sometimes PATCH endpoints that create resources or trigger side effects with real consequences: payments, order placement, sending an email or SMS, provisioning infrastructure. If retrying a failed request would be harmless either way, you don't need the machinery.
Takeaway
Idempotency keys turn "did that actually go through?" — a question your client can't reliably answer after a timeout — into a non-issue: retry with the same key, get the same result, exactly once. The pattern is simple, but the atomicity of the insert is what makes it actually safe under concurrent requests, not just under sequential ones.