
Idempotency Keys in Distributed Systems: The Rules That Save You From Duplicates
How Stripe-style idempotency actually works, what to store, how long to keep it, and the failure modes retries create when you get it wrong.
Any endpoint that changes state and can be retried needs an idempotency key. Store the key, request hash, and full response for 24 hours; on repeat, return the stored response byte-for-byte. Get this wrong and you'll charge cards twice, send duplicate emails, and lose customer trust you can't buy back.
- Retries are inevitable — networks fail, timeouts lie, clients re-fire. Design as if every call happens twice.
- The client generates the key (UUID), the server enforces it. Never let the server invent the key.
- Store: key, request hash, response body, response status, created_at.
- On key reuse with different body — return 422. That's the caller's bug, don't paper over it.
- TTL of 24 hours covers all real retry windows and keeps storage sane.
Why every write endpoint needs one
A client POSTs to /charges. The request hits your server, the DB commits, the response is on its way — and then the client's TCP connection times out at 30s. The client retries. Now you have two charges on the same card.
This isn't hypothetical. It happens every day at scale. The only defense is that the server remembers 'I already processed this request' and returns the same response on retry. That's an idempotency key.
The Stripe pattern, in one table
create table idempotency_keys (
key text primary key, -- client-supplied UUID
request_hash text not null, -- sha256 of method+path+body
status_code int not null,
response_body jsonb not null,
created_at timestamptz not null default now()
);
create index idempotency_keys_created on idempotency_keys(created_at);A background job deletes rows older than 24-48 hours. The primary key on key gives you free deduplication.
The handler flow
- Extract the
Idempotency-Keyheader. If missing, either reject (strict mode) or process normally (lax mode). - Compute
request_hash = sha256(method + path + canonical_json(body)). - Try to INSERT the key with the hash inside a transaction that also does the real work. Use ON CONFLICT DO NOTHING and check whether it took.
- If the insert succeeded (this is the first time), do the work and UPDATE the row with the response.
- If the insert conflicted, SELECT the existing row: if hashes match, return the stored response; if not, return 422 Conflict.
async def handle(request):
key = request.headers['Idempotency-Key']
body_hash = sha256(request.method + request.path + canonical(request.body))
async with db.transaction():
existing = await db.fetch_one(
'insert into idempotency_keys(key, request_hash, status_code, response_body) '
'values ($1, $2, 0, $3::jsonb) on conflict (key) do nothing returning key',
key, body_hash, '{}')
if existing is None:
row = await db.fetch_one('select * from idempotency_keys where key=$1', key)
if row['request_hash'] != body_hash: return 422, {'error': 'idempotency_key_reuse'}
if row['status_code'] == 0: return 409, {'error': 'in_progress'}
return row['status_code'], row['response_body']
result = await do_work(request)
await db.execute('update idempotency_keys set status_code=$1, response_body=$2 where key=$3',
result.status, result.body, key)
return result.status, result.bodyThe in-progress problem
What if the client retries while the first request is still running? The row exists with status_code = 0. Two options:
- Return 409 Conflict with a 'in progress, retry in a second' hint. Simple, correct, sometimes annoying for clients.
- Long-poll on the row until it completes. Better UX, more server complexity, watch for connection pool exhaustion.
Start with 409. Add long-polling only if the endpoint's normal latency is long enough that clients keep bumping into it.
Common mistakes I keep seeing
- Server generates the key. Now every retry has a different key and idempotency does nothing.
- Key without body hash. A buggy client reuses a key for a different request; you happily return the wrong response.
- Storing only the key, not the response. You know it was processed, but can't tell the client what happened.
- TTL too short. A client that retries after 6 hours (yes, they exist) hits a fresh row and double-charges.
- TTL too long. The table grows unbounded, indexes slow down, cleanup jobs pile up.
Where to enforce it
Idempotency lives at the outermost boundary of a request. In a microservice architecture, that's usually the API gateway or the individual service's HTTP handler — not inside the domain logic. Domain logic should assume it's always the first attempt; the idempotency layer either lets it run or returns a cached response.
The same principle applies to background workers consuming a queue: the queue key (message ID or a client-supplied dedup key) is the idempotency key, checked before the job's real work starts.
Frequently asked
GET requests too?
GETs are naturally idempotent by definition — no state changes. You don't need idempotency keys for them, though caching is a different question.
What about PUT? Isn't PUT idempotent already?
In the HTTP-spec sense, yes. In practice, PUT bodies often trigger side effects (webhooks, emails). Add idempotency keys anywhere retries could cause visible duplicates.
Can I use Redis instead of Postgres?
Yes, but you lose the transactional guarantee — the atomic 'insert idempotency row AND do the work' becomes 'set redis key THEN do work' with a race window. If your DB and Redis are separate, at least use SETNX with a long TTL.
Building something similar?
I help teams ship production-grade AI agents, n8n workflows, and data platforms. Let's talk about what you're building.
Work with me
Islam Gamal
AI, Data & Automation Engineer. I design and ship production AI agents, n8n workflows, and cloud data platforms — with a focus on reliability, cost, and measurable business impact. Founder of Tashghil and Tek bil Arabi.
More from Islam Gamal
Webhooks That Don't Lie: Idempotency, Signatures, Replay, and a Playbook
An opinionated production guide to receiving webhooks — signature verification, idempotency keys, store-then-process pipelines, dead-letter queues, replay UIs, provider quirks, and a local testing loop your team will actually use.
Event-Driven Architecture With Just Postgres (Until You Really Can't)
Outbox pattern, LISTEN/NOTIFY, logical replication, and pg_cron — the surprising amount of event-driven system you can run on one Postgres before reaching for Kafka.
Postgres for Product Engineers: Indexing, JSONB, RLS, and the 20% That Ships Millions of Users
The Postgres playbook I've used across product teams — index type per workload, JSONB without regret, RLS that doesn't leak or crawl, connection pooling that survives spikes, and the observability that turns guessing into ground truth.
Browse every article by Islam Gamal on the author page.