Idempotency Keys: Making POST Requests Safe to Retry
A client sends POST /payments, the network times out, and it retries. Did the first request succeed? Without protection, the customer may be charged twice. Idempotency keys make retries safe.
The contract
POST /payments
Idempotency-Key: 7f3b2c1e-9a4d-4e8b-a1f2-3c5d6e7f8a9b
{ "amount": 49900, "currency": "INR", "customer": "cus_42" }
- The client generates a unique key (a UUID) per logical operation and reuses it on retries.
- The server processes the first request and stores the response under that key.
- Any repeat with the same key returns the stored response without doing the work again.
Storage
CREATE TABLE idempotency_keys (
key TEXT NOT NULL,
account_id BIGINT NOT NULL,
request_hash TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'processing', -- processing | done
response_code INT,
response_body JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (account_id, key)
);
Handling a request
async function withIdempotency(req, res, handler) {
const key = req.get('Idempotency-Key');
if (!key) return handler();
const hash = sha256(JSON.stringify(req.body));
const inserted = await db.query(
`INSERT INTO idempotency_keys (key, account_id, request_hash) VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING RETURNING key`,
[key, req.account.id, hash]
);
if (inserted.rowCount === 0) {
const existing = await db.one('SELECT * FROM idempotency_keys WHERE key = $1 AND account_id = $2', [key, req.account.id]);
if (existing.request_hash !== hash) return res.status(422).json({ error: 'Key reused with a different request' });
if (existing.status === 'processing') return res.status(409).json({ error: 'Request still in progress' });
return res.status(existing.response_code).json(existing.response_body);
}
const { status, body } = await handler();
await db.query(
`UPDATE idempotency_keys SET status = 'done', response_code = $3, response_body = $4
WHERE key = $1 AND account_id = $2`,
[key, req.account.id, status, body]
);
return res.status(status).json(body);
}
Details that matter
- Scope keys per account so one customer can't collide with another.
- Reject a reused key with a different body; it's almost always a client bug.
- Expire keys after 24 hours or so.
- If the handler fails with a server error, delete the key (or allow retry) so the client can try again.
Was this article helpful?
Comments
No comments yet. Have a question or a tip? Be the first to share it.