A few years ago, a client’s payment API lit up one morning with a 3.2% duplicate-charge report. More than 400 customers had been charged twice. The root cause was simple: a missing idempotency layer.
Ever since, adding idempotency to every critical API endpoint has been a non-negotiable step. Here’s a practical implementation and the subtleties you shouldn’t skip.
Why you need it
The internet is unreliable. The client sends a request, the network times out. Thirty seconds later the client retries. But the first request did reach the server, the response just never came back.
Now the backend sees the same operation twice:
- Same customer charged twice
- Same order written twice
- Same SMS sent twice
Idempotency fixes this: if the client retries with the same operation key, the server returns the same response without re-running the operation.
Practical implementation
The client generates an Idempotency-Key header (UUID v4) per unique operation. Every retry uses the same key.
On the backend:
- When a request comes in, read the header key
- Check Redis or the DB for a previously stored response under that key
- If it exists, return it directly, do not run the operation again
- Otherwise, run the operation, store the result under the key with a 24-hour TTL, and return the response
I use Redis as the store. Fast, and TTL is native.
Don’t miss the race condition
That flow has a hole: if two requests arrive in parallel, neither finds a cache entry, both start processing, and you end up with a duplicate charge anyway.
You need a distributed lock. With Redis’s SETNX you acquire a lock on the key, hold it until the operation completes, then release it. A concurrent request that can’t grab the lock either waits or gets a 409.
Store the full response, not just a success flag
This is a detail but it matters. The client needs to see the same response, not just “yep, it worked”. If the original response returned transaction_id: txn_abc123, the retry has to see the same ID.
So the idempotency store holds the full response body plus the status code, not a boolean flag.
Pick the right TTL
How long should you keep an idempotency key?
- Too short (1 minute): you miss retries that come minutes later
- Too long (1 year): storage bloat, and key-collision risk creeps up
Stripe uses 24 hours. That’s a solid default in practice. I use the same. If you have a workflow that runs longer than 24 hours, hold that specific key for longer explicitly.
Where it doesn’t help: over-engineering
Don’t bolt idempotency onto every endpoint. Where you need it:
- POST /payments, /charges, /refunds
- POST /orders, /subscriptions
- POST /messages, /emails, /sms
- PUT /accounts (anything that mutates balances)
Where you don’t (already idempotent by design):
- GET endpoints, same result every time
- PUT /profile (replaces the resource, same input yields the same state)
- DELETE /items/{id} (after the first delete you get 404)
Simple rule: if the operation produces an external side effect (payment, email, notification), you need idempotency. If it only mutates the DB in an idempotent way (profile update), you don’t.
Response pattern
When the server replays a cached result, add Idempotency-Replayed: true to the response so the client can tell it was served from cache. Useful for debugging.
How to test it
Before shipping, fire the same key three times in a row. All three should return the same response, and the side effect should happen exactly once. Encode that scenario in an integration test.
Takeaway
Idempotency isn’t “nice to have”, it’s hygiene for critical APIs. After getting burnt by a duplicate-charge incident, I now have a standard implementation. I’d recommend you do the same, because one day it’ll happen to you too, and being ready is cheap.