Every payment integration eventually ships the same bug. A client sends POST /charge, the request succeeds, the response times out in transit, the client retries, and the customer is billed twice. Nobody wrote incorrect logic. The bug lives in the gap between "the work happened" and "the caller learned that the work happened", and no amount of careful coding closes that gap on its own.
Idempotency keys close it. The idea is small enough to explain in one sentence: the caller attaches a unique key to a request, and the server promises that the same key will produce the same outcome exactly once, no matter how many times it arrives.
Why retries are not optional
If you run anything over a network, retries are already happening. HTTP clients retry. Load balancers retry. Your own SDK retries on connection reset. Kubernetes restarts pods mid-request and the work is delivered again by a queue redelivery you did not configure.
So the question is never "should we handle duplicates". The question is whether you handle them deliberately or discover them in a support ticket at 2am with a customer asking why their card was charged three times.
The four lines that make it work
The storage design is what people get wrong. An idempotency record needs to capture three things: the key, the request fingerprint, and the result. The result matters as much as the key, because a retry must return the original response, not just refuse to repeat the work.
create table idempotency_keys (
key text primary key,
request_hash text not null,
status text not null default 'in_progress', -- in_progress | done
response_body jsonb,
response_code int,
created_at timestamptz not null default now(),
expires_at timestamptz not null
);
create index on idempotency_keys (expires_at);
The primary key on key is the whole trick. It converts "have I seen this before?" from an application-level check into a database guarantee, which means it stays correct when two requests arrive at the same millisecond on different machines.
The part nobody warns you about
The naive flow — read the row, do the work, write the row — has a race. Two concurrent retries both read "no record", both charge the card, and both write. The primary key does not save you, because neither transaction tried to insert until after the damage was done.
The fix is to make the insert the claim:
def claim(key: str, request_hash: str) -> Claim:
try:
db.execute(
"insert into idempotency_keys (key, request_hash, expires_at)"
" values (%s, %s, now() + interval '24 hours')",
(key, request_hash),
)
db.commit()
return Claim.ACQUIRED
except UniqueViolation:
row = db.fetch_one("select * from idempotency_keys where key = %s", (key,))
if row["request_hash"] != request_hash:
# Same key, different body: the caller has a bug. Refuse loudly.
raise Conflict("idempotency key reused with a different payload")
if row["status"] == "done":
return Claim.REPLAY(row["response_code"], row["response_body"])
return Claim.IN_PROGRESS
Three outcomes, and each one deserves a different HTTP status. ACQUIRED means do the work. REPLAY means return the stored response verbatim, with the original status code — a replayed 201 must not become a 200. IN_PROGRESS means a concurrent attempt is running right now, and the correct answer is 409 Conflict with a Retry-After, not a second charge.
That request_hash comparison is the detail that separates a robust implementation from a dangerous one. Without it, a buggy client that reuses a key for a different payload gets the previous response silently, and you have created a data corruption mechanism that looks like a reliability feature.
Expiry is a product decision, not a technical one
Keys cannot live forever. The window should cover the longest plausible retry chain: client-side backoff, a queue redelivery, and a human clicking the button again after lunch. Twenty-four hours covers all of that for most products. Card networks care much less about your key than you do — the money moving twice is the problem, not the row.
Set expires_at when you insert, delete on a schedule, and index the expiry column so the cleanup job is a range scan instead of a table scan. If you skip the index, the cleanup query becomes the next incident.
What it costs
Forty lines, one table, one index, one cron job. Compare that against the cost of a duplicate-charge incident: refunds, a reconciliation script written under pressure, a support conversation you cannot automate, and a customer who now checks their statement.
Reconciliation is work you do after the fact with incomplete information. Idempotency keys are work you do before the fact with complete information. Ship the keys.
I write about production failures in Postgres, queues, and distributed systems.
Top comments (3)
Making the insert the claim rather than read-then-write is the decision the whole thing rests on, and the
request_hashcheck on theUniqueViolationbranch is what keeps it from becoming a silent-wrong-response machine.Also worth a line: metering. On a usage-priced API the quota unit usually gets consumed in auth, before the handler reaches
claim()at all, so a genuine replay arrives already billed and the dedup path has to refund it explicitly. Same-key-different-body is the one branch we deliberately don't refund, because the work already ran to get there and refunding it would let someone reuse one key with varying bodies to mint free compute.Did the 24 hour window ever bite you on a queue redelivery that outlived it?
Good catch on metering — that is a real gap in the post. Quota spent in auth means a genuine replay arrives already billed, and the dedup path has to refund it explicitly. I would rather move the claim ahead of the metering than refund after the fact, because a refund is a second write that can itself fail, and then you need idempotency for your idempotency.
On the 24 hour window: it bit us in exactly the direction you would expect. I had derived the window from our own retry policy, which is the wrong source. It has to come from the longest retention on any producer. If a broker can redeliver after 26 hours, a 24 hour window is a 2 hour hole that no test will ever show you.
The rule I use now: the key is a cache of "did I already answer this caller". The job row is the durable record. The key should never be the only thing standing between you and a double charge.
And agreed on same-key-different-body — refunding that one turns the key into a free-compute primitive.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.