Tech · 6 min read
Idempotency in APIs: Why It Matters and How to Actually Implement It
Networks retry. Idempotency is what keeps a single user click from creating two charges. A practical guide to designing idempotent APIs without painting yourself into a corner.
By Jarviix Engineering · Apr 19, 2026
Networks fail in the middle. Mobile clients drop connections at the worst times. Load balancers retry. Users mash buttons. None of these are bugs — they're the environment your API runs in. Idempotency is the design property that keeps any of them from creating two charges, two orders, or two of anything that should only exist once.
This post walks through the practical mechanics — what idempotency really means, where to apply it, and how to implement it without painting yourself into a corner.
The definition that actually matters
The textbook definition: an operation is idempotent if calling it N times produces the same result as calling it once.
The useful definition for API design: the second identical call from the same client should not create a second side effect, but should return a sensible response that lets the client believe they succeeded.
That second part is the part most teams forget. It's not enough to skip the duplicate write — the client needs to know "yes, your charge went through, here's the receipt", not "unknown error".
When you actually need it
Three categories of operations need idempotency, in roughly decreasing order of urgency:
- Things that move money. Charges, refunds, transfers, settlements. A duplicate is real harm to a real person.
- Things that send notifications. Emails, SMS, push notifications, webhooks. A duplicate is a confused user and a CX ticket.
- Things that mutate critical business state. Creating an order, scheduling a job, provisioning a resource. A duplicate is a data quality problem and an ops headache.
For everything else (analytics events, logs, idempotent reads), it's nice-to-have. For the three above, it's required.
The standard pattern: idempotency keys
The most widely adopted approach (Stripe, AWS, every payment processor):
The client generates a unique key per logical operation and sends it on every retry of that operation.
POST /charges
Idempotency-Key: 6bf8e4c2-2a1c-4b9c-9d3a-9e5b1c5b8c2e
Content-Type: application/json
{ "amount": 1999, "currency": "INR", "customer": "cus_42" }
The server stores the key, the request hash, and the eventual response. On retry:
1. Look up the key.
2. If found and complete → return the cached response. Don't re-execute.
3. If found and in-flight → return 409 (or wait briefly).
4. If not found → execute, store the response, then return.
The contract with clients is: "send the same key for retries; generate a new one for genuinely new operations."
The race condition that bites everyone
Naïve implementations have a window:
T1: Request A arrives, key not found. App starts executing.
T2: Request A (retry) arrives, key not found. App starts executing again.
T3: Both inserts succeed. Two charges. Sad customer.
The fix is making "check + claim" atomic. Two ways:
Database-side claim. Before doing the work, insert the key with status in_progress using a unique constraint:
INSERT INTO idempotency_keys (key, status) VALUES ($1, 'in_progress')
ON CONFLICT (key) DO NOTHING RETURNING id;
If the insert returns no row, someone else already claimed it. Wait briefly and re-read the row to get their result.
Redis claim. A SET key value NX EX 60 is atomic — only one caller wins the claim. Use a Lua script if you also want to read the previous result in one round trip.
Either way: never proceed past the claim step without atomically owning the key.
What to do with the claim
Once you've atomically claimed the key:
1. Execute the operation in a database transaction (if possible).
2. Inside the same transaction, update the idempotency record with the response.
3. Commit.
The key insight: keep the idempotency record and the business write in the same transaction if the same database is involved. Otherwise you can write the charge but crash before recording the response, and the next retry creates a duplicate.
If the business write goes to an external system (a payment processor, a third-party API), this gets harder. The pattern there is:
1. Claim the key.
2. Call the external system.
3. Persist the external system's response next to the idempotency record.
4. The external system itself should also be idempotent.
Most modern payment processors accept idempotency keys themselves — pass yours through. That way the chain is idempotent end-to-end.
What the response should look like
When a retry hits a completed key, return what you returned the first time, including the original status code:
201 Created
Idempotent-Replayed: true
{ "id": "ch_abc", "status": "succeeded", ... }
Returning the original 201 (not a 200 or 409) lets generic client code keep working. The Idempotent-Replayed header is optional, useful for debugging.
If the request body differs from the one that originally created the key (same key, different amount), reject with 409 Conflict. The client used the same key for two different operations — that's a bug they need to know about.
What to store
For each idempotency record:
- The key itself.
- A hash of the canonical request body (to detect "same key, different body").
- The HTTP status returned.
- The response body returned.
- A timestamp.
- A status:
in_progress,completed,failed.
24 hours of retention is the typical default for payment APIs. Longer if your retries have longer windows.
Common mistakes
A few that have bitten me or teams I've worked with:
- Letting the server generate the key. Defeats the purpose. The whole point is the client generates the key once and reuses it across retries; if the server generates it, the client can't deduplicate its own retries.
- Not validating the request body matches. Same key, different amount → silently re-using the cached response. Customer gets the wrong receipt.
- Treating idempotency keys as authentication. They're not secret. Anyone with a key can replay the response. Auth is a separate layer.
- Skipping the database transaction. Idempotency record committed but business write rolled back, or vice versa. The next retry sees "completed" and returns a fake success.
- Cleaning up keys too aggressively. A retry storm 30 seconds after a 5-second TTL is the worst possible time for the key to be gone.
A minimal implementation sketch
In Python with FastAPI and Redis, the core check fits in 25 lines:
async def with_idempotency(key: str, body_hash: str, fn):
claim = await redis.set(
f"idem:{key}",
body_hash + ":in_progress",
nx=True, ex=86400,
)
if not claim:
cached = await redis.get(f"idem:{key}")
if cached.endswith(":in_progress"):
raise ConflictError("retry shortly")
stored_hash, _, payload = cached.partition(":done:")
if stored_hash != body_hash:
raise ConflictError("idempotency key reused with different body")
return json.loads(payload)
result = await fn()
await redis.set(
f"idem:{key}",
f"{body_hash}:done:{json.dumps(result)}",
ex=86400,
)
return result
Wrap any side-effecting handler in this and you've eliminated 99% of duplicate-write bugs from the very first deploy.
What to read next
Idempotency is half of "your API survives bad networks". The other half is rate limiting. And if you're picking an API style at the same time, REST vs GraphQL vs gRPC covers the layer above. The notification service LLD writeup shows what idempotency keys look like at object-model depth — exactly the surface where retries and duplicates collide most often.
Frequently asked questions
Aren't GET and PUT already idempotent?
By HTTP spec, yes. By application behavior, only if you actually wrote them that way. Idempotency is a property of your handler, not your verb.
How long should I keep idempotency keys?
Long enough to cover the worst-case retry storm — usually 24 hours for payment-style APIs, sometimes 7 days for important business operations. Beyond that, the value of catching a duplicate is lower than the cost of storing the key.
Where do I store the keys?
A fast key-value store (Redis is typical), or a dedicated table in the same database as the operation if you need atomicity with the business state.
Read next
Apr 19, 2026 · 6 min read
API Versioning Strategies: URL, Header, and the Trade-offs Nobody Tells You
URL versioning, header versioning, content negotiation, and 'no versioning at all' — what each costs, what each gets you, and how to pick a strategy you won't regret in three years.
Apr 19, 2026 · 6 min read
Eventual Consistency: What It Really Means in Production
What eventual consistency actually buys you, what it costs your users, and the patterns (read-your-writes, monotonic reads, quorum reads) that make it bearable.
Apr 19, 2026 · 6 min read
Kafka Explained Simply: Topics, Partitions, Consumers, and the Mental Model That Makes It Click
Kafka isn't a queue — it's a distributed log. Once you internalize that one shift, the partitions, consumer groups, offsets, and replay semantics all start to make sense.