Retries are the silent cause of double charges and duplicate records in SaaS products. A client times out, resends the request, and your system processes it twice. This guide shows you how to make write operations idempotent so the same request can arrive many times but only take effect once. You will learn where to put the key, how to store it, and the edge cases most teams miss.
What idempotency actually means
An operation is idempotent when performing it multiple times produces the same result as performing it once. Reading data is naturally idempotent. Creating a charge or a subscription is not. If two identical POST requests hit your billing endpoint, you get two charges unless you deliberately prevent it.
The root problem is that networks are unreliable. When a client does not receive a response, it cannot tell whether the request failed before or after your server did the work. Its only safe move is to retry. Your server must therefore recognize the repeat and return the original outcome instead of doing the work again.
The core pattern: idempotency keys
The client generates a unique key (a UUID is fine) for each logical operation and sends it in a header, commonly Idempotency-Key. The server stores that key with the result. This is the same pattern the Stripe API uses, and it is worth copying because it is battle-tested.
The server-side flow
- Receive the request and read the idempotency key.
- Try to insert a row for that key with a status of
in_progress. Use a unique constraint on the key column so a duplicate insert fails atomically. - If the insert succeeds, you own the request. Do the work, then save the response body and status against the key.
- If the insert fails because the key exists, look up the stored result. If it is complete, return the saved response. If it is still
in_progress, return a 409 so the client retries later.
The unique constraint is the load-bearing part. It turns a race between two concurrent retries into a single winner at the database level, without application locks.
Scope the key to the request
Store a fingerprint of the request body alongside the key. If a later request reuses the key but sends a different body, reject it with an error. This stops a client bug from silently returning a stale, unrelated result.
A real scenario: the double subscription
A team I worked with had a checkout endpoint that created a subscription and charged the first invoice. On flaky mobile networks, roughly a small but steady stream of customers ended up with two subscriptions. The client retried after a 30-second timeout, but the server had actually succeeded on the first try; the response just never arrived.
The fix was one idempotency key per checkout attempt, generated when the user tapped Pay, plus a unique index on that key. Duplicate subscriptions stopped immediately. No pricing logic changed. The bug was never in the billing math; it was in assuming each request arrives exactly once.
Webhooks need the same treatment, reversed
When you receive webhooks (from a payment provider, for example), you are now the client that must handle duplicates. Providers guarantee at-least-once delivery, so the same event can arrive twice. Store each event ID you have processed and skip events you have seen. Never assume a webhook fires exactly once.
Common mistakes and how to fix them
- Using a lock instead of a unique constraint. Application locks leak on crashes. Fix: rely on a database unique constraint so atomicity is guaranteed even if a process dies mid-request.
- Storing the key but not the response. You detect the duplicate but cannot return the original result, so you error out. Fix: persist the full response body and status code.
- Keys that never expire. The table grows forever. Fix: expire keys after a defined window (24 hours is common) and document that clients must not retry beyond it.
- Making the key too broad or too narrow. One key for a whole session merges unrelated operations; a new key per retry defeats the purpose. Fix: one key per logical operation, reused across that operation’s retries.
- Ignoring in-progress collisions. Two retries land at once and both try to work. Fix: the second sees
in_progressand gets a 409 to retry, not a second execution.
Action checklist
- Identify every write endpoint that has side effects (charges, emails, provisioning).
- Require an idempotency key on each and validate it is present.
- Add a unique constraint on the key column in your store.
- Persist request fingerprint, response body, and status per key.
- Return 409 for in-progress duplicates; return the saved response for completed ones.
- Expire keys on a schedule and deduplicate inbound webhooks by event ID.
Conclusion and next step
Idempotency is not an optimization; it is correctness under real network conditions. Start with your highest-risk endpoint, usually billing, and add keys there this week. Once the pattern is proven, roll it out to every side-effecting write.
FAQ
Do GET requests need idempotency keys?
No. Reads do not change state, so repeating them is already safe. Focus keys on POST and other write operations with side effects.
Who should generate the key, client or server?
The client, because only the client knows when a retry is the same logical operation. The server records and enforces it.
How long should I keep idempotency keys?
Long enough to cover realistic retry windows. A 24-hour retention is a common, practical default; match it to how long your clients are allowed to retry.
Is a unique constraint really enough for concurrency?
For the deduplication step, yes. The database rejects the second insert atomically, which cleanly picks one winner without application-level locking.
References
- Stripe API documentation, section on idempotent requests.
- MDN Web Docs, definition of idempotent HTTP methods.
