· 7 min read
Idempotency keys: how to make API retries safe
Idempotency keys let clients retry API requests without double charges or duplicate orders. Learn how they work, how to store them and the races to avoid.

An idempotency key is a unique value the client sends with a request so the server can recognize a retry of something it has already done. The server stores the key with the result of the first attempt, and when the same key shows up again it returns that stored result instead of running the operation a second time. Idempotency keys are how you make retries safe for operations like payments, orders and emails, where doing the work twice is a real problem.
Retries are unavoidable in distributed systems. A request can time out after the server finished the work but before the response reached the client. From the client's side, a lost response and a failed request look identical. Without some way to deduplicate, the only safe options are "never retry" or "sometimes charge the customer twice".
What does idempotent mean for an API?
An operation is idempotent if performing it once or many times leaves the system in the same state. Setting a user's email to a specific value is idempotent. Adding $10 to a balance is not.
HTTP already defines this for its methods. GET, HEAD, PUT and DELETE are specified as idempotent: sending the same PUT twice should end with the same resource as sending it once. POST and PATCH are not. That is a problem, because POST is exactly what most APIs use to create things: a charge, an order, a message.
Note that idempotent describes the effect on server state, not the response. A second DELETE on the same resource might return 404 instead of 204, and it is still idempotent because nothing more was deleted.
An idempotency key turns a non-idempotent POST into one that behaves idempotently, as long as the client reuses the same key for retries of the same logical operation.
How do idempotency keys work?
The usual convention is a request header, typically named Idempotency-Key, holding a random value such as a UUID. The client generates the key once per logical operation, before the first attempt, and sends the same key on every retry.
POST /v1/charges HTTP/1.1
Idempotency-Key: 6f1c2a9e-3b7d-4e0a-9c55-0d2f8b41e7aa
Content-Type: application/json
{"amount": 500, "currency": "usd", "customer": "cus_123"}
On the server, each request goes through roughly these steps:
- Look up the key. If it does not exist, record it as in progress along with a hash of the request body, then do the work.
- When the work finishes, store the response (status and body) against the key and mark it done.
- If the key exists and is done, return the stored response without doing anything else.
- If the key exists and is still in progress, reject the request with a
409 Conflictso the client retries later. - If the key exists but the request body hash is different, reject it with an error such as
422. The client reused a key for a different operation, which is a bug.
Here is a minimal version in JavaScript with an in-memory map, to show the logic:
const store = new Map();
async function handleCharge(key, body) {
const hash = JSON.stringify(body);
const existing = store.get(key);
if (existing) {
if (existing.hash !== hash) return { status: 422, body: { error: "key reused with a different request" } };
if (existing.state === "in_progress") return { status: 409, body: { error: "request in progress" } };
return existing.response; // replay, no second charge
}
store.set(key, { hash, state: "in_progress" });
const charge = await chargeCard(body.amount);
const response = { status: 201, body: charge };
store.set(key, { hash, state: "done", response });
return response;
}
Calling this twice with the same key and body returns the same charge ID both times, and the card is charged once. Reusing the key with a different amount gets a 422. Two concurrent calls with the same key produce one 201 and one 409. The in-memory version only works because there is no await between the lookup and the first set, so nothing can interleave there. Across multiple server instances, that guarantee has to come from the database.
How do you store idempotency keys safely under concurrency?
The race to worry about: two copies of the same request arrive at two different servers at the same moment. Both look up the key, both find nothing, both do the work. A "check, then insert" sequence in application code cannot prevent this.
The fix is to let a unique constraint decide who wins. Make the key the primary key and insert it before doing any work:
CREATE TABLE idempotency_keys (
key TEXT PRIMARY KEY,
request_hash TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'in_progress',
response TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO idempotency_keys (key, request_hash)
VALUES ($1, $2)
ON CONFLICT (key) DO NOTHING
RETURNING key;
If the insert returns a row, this request owns the key and should do the work. If it returns nothing, another request already claimed it, so you read the existing row and replay, reject or ask the client to wait. This syntax works in PostgreSQL and in recent SQLite versions.
A few details matter more than they look:
- Scope keys to the caller. Make the uniqueness
(account_id, key)rather thankeyalone, so one client cannot collide with, or read the stored response of, another. - Write the result in the same transaction as the side effect when the side effect is a local database write. If the order row commits but the idempotency row does not, a retry will create a second order.
- Handle crashes mid-request. A process can die after claiming the key but before finishing. Store a locked-until timestamp or check
created_at, and let a later retry take over a key that has been in progress for too long. - Expire old keys. Retries happen within minutes or hours, not months. Keeping keys for around 24 hours is common, and a periodic delete keeps the table small.
What about side effects in other systems?
An idempotency key protects your API from duplicate requests. It does not make a call to a third-party service safe by itself. If your handler charges a card through a payment provider and crashes before recording the result, a retry will charge again unless that provider also accepts an idempotency key. Pass a key downstream whenever the provider supports one, and derive it from your own key so the chain stays consistent.
For events and messages, the same idea shows up on the consumer side. Most queues deliver at least once, meaning a message can arrive more than once. Consumers deduplicate by storing processed message IDs, which is an idempotency key under a different name. The transactional outbox pattern pairs naturally with this: the outbox guarantees the event is published, and idempotent consumers make the occasional duplicate harmless.
On the client side, the key must be created once and reused. A retry loop that generates a fresh UUID on every attempt defeats the whole mechanism. Generate the key before the loop, then retry with exponential backoff and jitter so the retries themselves don't pile up.
| Approach | Prevents duplicates across instances | Handles concurrent duplicates | Typical use |
|---|---|---|---|
Naturally idempotent operation (PUT, upsert) | Yes | Yes | Setting state to a known value |
| Idempotency key with unique constraint | Yes | Yes, with 409 for in-flight | Payments, orders, any create |
| Check-then-insert in app code | No | No | Avoid |
| Client-side "don't retry" | Not applicable | Not applicable | Gives up reliability |
Key takeaways
- A timeout does not tell the client whether the work happened, so retries need deduplication.
- The client generates one idempotency key per logical operation and reuses it on every retry.
- Let a database unique constraint claim the key before doing work; application-level checks race.
- Store a request hash to catch key reuse, and return
409while the first attempt is still running. - Pass keys to downstream providers and deduplicate in queue consumers, or the guarantee stops at your API.
FAQ
Should the client or the server generate the idempotency key?
The client. Only the client knows that two requests are attempts at the same operation, so it must create the key before the first attempt and send it on every retry.
How long should idempotency keys be stored?
Long enough to cover any realistic retry window. Around 24 hours is a common choice; after that, a request with an old key is treated as new.
Do GET requests need idempotency keys?
No. GET is already safe and idempotent, so repeating it has no side effects. Keys are for POST and other requests that create or change something.