On this page · 13 sections
Summary. A payment POST times out after 2 seconds, the client shows an error, the user taps "pay" again, and now a $20.00 charge has hit the card twice. Idempotency keys stop exactly that. The client sends a unique value in an Idempotency-Key header, the server stores the first response against that key, and every retry returns the stored response instead of running the operation again. Stripe and Adyen have offered this for years, Stripe expires keys after 24 hours, and the pattern is now an IETF Internet-Draft, "The Idempotency-Key HTTP Header Field," whose draft 07 carried an expiry of 18 April 2026. This guide shows how the header works, how to implement it on the server without a race condition, how long to keep keys, and the security traps to avoid. In India, where a double-charged ₹2,000 order means a support ticket and a refund, this is not optional plumbing.
As API author Phil Sturgeon puts it: "Idempotency is when doing an operation multiple times is guaranteed to have the same effect as doing it just once." Some HTTP methods already have this property. GET, HEAD, PUT, DELETE, OPTIONS, and TRACE are defined as idempotent, so repeating DELETE /users/123 leaves user 123 deleted either way. POST and PATCH are the problem. They exist to cause state changes, and sometimes you genuinely want the same request to happen twice, so the server cannot simply block duplicates. The idempotency key is how the client tells the server which case this is.
Which methods need a key
The header only matters for the non-idempotent methods. This table shows where a key earns its keep.
| Method | Idempotent by default? | Needs a key for safe retry? |
|---|---|---|
| GET | Yes | No |
| PUT | Yes | No |
| DELETE | Yes | No |
| POST | No | Yes |
| PATCH | Not guaranteed | Often yes |
PATCH is the subtle one. Many PATCH requests are idempotent by design, but the HTTP specification does not require it. A JSON Patch operation like incrementBy: 1 is clearly not idempotent, because sending it twice increments the value twice. When a PATCH can change state cumulatively, treat it like POST.
Why a retry is dangerous
Picture a payment client with a 2-second timeout:
await axios.post(
'/payments',
{ to: 'user@example', value: 2000 },
{ timeout: 2000 }
);
If the call does not finish in 2 seconds, the HTTP client reports a failure and offers a retry. The trouble is that the client does not know whether the server completed the payment or was merely slow to return the JSON. The server, meanwhile, does not know the client gave up. Without more information, a second POST /payments is indistinguishable from a legitimate second payment. As Sturgeon frames it, if the server receives the same charge twice, "it could be a failed retry, or it could just as easily be somebody paying Julian for a second cider." The key resolves the ambiguity.
The Idempotency-Key header
The client generates a unique value and sends it in the Idempotency-Key header. Here is the Stripe form, using curl:
curl https://api.stripe.com/v1/charges \
--user sk_test_xxx: \
--header "Idempotency-Key: uWeBuDsZPxxvdhND" \
--data amount=2000 \
--data currency=usd \
--data source=tok_mastercard
Any later request with the same key does not create a new charge; the server returns the saved response from the first call. Stripe recommends V4 UUIDs or another random string with enough entropy, limits keys to 255 characters, and warns against putting sensitive data such as email addresses in the key. Sending the same key with different parameters returns an error, because the new request does not match the original.
This is now standardised work. The IETF's HTTPAPI working group, the same group behind RFC 9457 problem details, has an Internet-Draft called "The Idempotency-Key HTTP Header Field." It is documented on MDN and increasingly implemented, but it remains a draft rather than a finished RFC, so treat the fine print as still moving.
Implementing it on the server
Start with a plain POST endpoint in Express that does no deduplication:
app.post("/things", (req, res) =>
res.json({ message: `New random number: ${Math.random() * 100}` })
);
Every call returns a different number, so a client cannot safely retry. Add middleware to check for a key and short-circuit on a repeat. Sturgeon uses the express-idempotency package:
const idempotency = require("express-idempotency");
app.use(idempotency.idempotency());
app.post("/things", (req, res) => {
const service = idempotency.getSharedIdempotencyService();
if (service.isHit(req)) return; // repeat: stored response already sent
res.json({ message: `New random number: ${Math.random() * 100}` });
});
Now three calls with no key each run the operation, which is the correct behaviour for existing clients, but three calls carrying Idempotency-Key: cheesecake all return the identical response. On the client, generate the key when the user starts an operation:
const { v4: uuidv4 } = require('uuid');
const idempotencyKey = uuidv4(); // set when the form loads
fetch("https://example.org/api/things", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(payload),
});
Key lifetime is a UX decision. If the user expects a new operation on every submit, reset the key after each success. If they are retrying one operation, keep the same key throughout.
The race condition most tutorials miss
The in-memory demo above breaks in production for two reasons. First, several servers behind a load balancer each hold their own local state, so a key seen by one is unknown to another. Second, an in-memory store is wiped on every deploy. You need shared, durable storage.
There is a subtler bug too. The naive "check if the key exists, then act" pattern has a race: two concurrent requests with the same key can both pass the existence check before either writes, and both run the side effect. The fix is to make a database INSERT with a UNIQUE constraint on the key the very first operation. The database detects the conflict atomically, so exactly one request wins. On a unique-constraint violation, you return the stored response instead of processing again.
A workable idempotency records table looks like this:
CREATE TABLE idempotency_records (
idempotency_key TEXT PRIMARY KEY,
request_hash TEXT NOT NULL,
response_status INT,
response_body JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_idempotency_expires ON idempotency_records (expires_at);
Insert the key first, inside the same transaction as the business write where possible, then store the status and body once the work succeeds. The index on expires_at lets a cleanup job remove stale rows cheaply.
Storage and TTL
Where you store keys is a trade-off between speed and durability.
| Approach | Durability | Best for |
|---|---|---|
| In-memory | None; lost on restart | Local demos only |
| Redis with TTL | Configurable, fast | High-volume checks outside the business transaction |
| Relational DB, unique constraint | Strong, transactional | Payments and orders that must be exactly-once |
The TTL rule is simple: the record must outlive the client's retry window. If a provider retries for 48 hours, the deduplication data has to persist at least 48 hours. Stripe expires keys after 24 hours. Keep the window measured in hours or days, never years, both to limit storage growth and to reduce the reuse risk covered below.
Compare the request, not just the key
Storing a request_hash is not optional detail; it is a correctness and security control. Before you reuse a stored response, compare the new request against the original by hashing the body alongside the key. This does two things. It catches the honest bug where a client reuses a key with a changed payload, which should return a mismatch error rather than the wrong saved response. And it makes it far harder for one party to reuse a known key without also knowing the full original request. Libraries such as express-idempotency do this comparison automatically.
Security considerations
A reused key returns the stored response regardless of who sends it. If two different clients send Idempotency-Key: same-thing, the second could receive the first client's response. That can leak data directly, or leak the mere existence of an operation that should be private, such as whether one user ever paid another. Sturgeon lists the defences, and they are worth following exactly.
Use sufficiently random values such as UUIDs, so independent clients effectively never collide. Expire keys after a short time, hours or days, so they cannot be replayed indefinitely. Compare the request body before reusing a response. Run authentication and rate limiting before the idempotency check, so the deduplication layer cannot be brute-forced anonymously. And treat keys as best-effort: for an irreversible, high-value action, do not rely on an idempotency key as the only safeguard; check explicitly whether the operation already happened before retrying after a long gap. When a key mismatch or conflict occurs, return the error in a structured format like RFC 9457 problem details so clients can react programmatically.
Common failure modes
Idempotency keys are harder than the header makes them look, and a 2026 survey of production incidents keeps surfacing the same handful of mistakes. Knowing them upfront saves a painful debugging session.
Storing the response too early is the first. If you write the stored response before the business transaction commits, a crash between the two leaves a key that maps to a success the system never actually performed, and the retry returns a phantom result. Insert the key first, but write the response only after the real work commits, ideally in the same transaction.
Setting the TTL shorter than the retry window is the second. A cache that expires after 6 hours cannot protect a client that retries after 12, and the operation runs twice. Match the record's lifetime to the longest realistic retry, then add a margin.
Skipping the request fingerprint is the third. Without hashing the body, a client that reuses a key with a new payload silently gets the old response, and the two operations quietly merge. A ₹500 top-up and a ₹5,000 top-up sharing a key is a bug you never want to explain to a customer.
Relying on the key as the sole guarantee for irreversible actions is the fourth. Keys are best-effort. For a wire transfer or an account deletion, add an independent check that the operation has not already run, rather than trusting the key alone.
The fifth is treating in-memory state as durable across a fleet. Behind a load balancer, or across a deploy, local state disappears, so a shared store is mandatory the moment you run more than one instance.
India-specific considerations
For teams shipping payments, quick-commerce, or D2C checkout flows in India, idempotency is a direct line to fewer refunds and support tickets. A retried UPI or card POST that double-charges a ₹2,000 order costs real money and trust to unwind. Two India-flavoured notes. First, the Idempotency-Key and stored request must not contain personal data, which keeps the deduplication store clean under the DPDP Act. Second, on flaky mobile connectivity, retries are frequent, so the payoff from correct idempotency is larger than in a data-centre-to-data-centre setting. Build it into the API contract from day one rather than retrofitting after the first double-charge incident. If you are consolidating services, do it as part of a wider monolith-to-microservices modernization so every service shares one idempotency module.
FAQ
How eCorpIT can help
eCorpIT builds and hardens payment and checkout APIs for clients in India and abroad, and safe-retry handling is part of that work. If your team wants an idempotency layer designed for exactly-once payments, a review of your current retry behaviour, or a shared module across a set of microservices, our senior engineering team can scope it with you. Reach us through the contact page.
References
- Phil Sturgeon, Working with the new Idempotency Keys RFC, HTTP Toolkit blog, December 2023.
- Stripe, Designing predictable APIs with idempotency, Stripe engineering blog.
- Stripe, Idempotent requests, API reference, docs.stripe.com.
- IETF, The Idempotency-Key HTTP Header Field (Internet-Draft), datatracker.ietf.org.
- MDN, Idempotency-Key header, developer.mozilla.org.
- Brandur Leach, Implementing Stripe-like Idempotency Keys in Postgres, brandur.org.
- Zuplo, Implementing Idempotency Keys in REST APIs, zuplo.com.
- OneUptime, How to Handle Idempotency in Microservices, oneuptime.com, January 2026.
- Adyen, API idempotency, docs.adyen.com.
- IETF HTTPAPI working group, idempotency repository, GitHub.
- Java Code Geeks, Idempotency Keys Are Harder Than They Look, June 2026.
- Luca Palmieri, An In-Depth Introduction To Idempotency, lpalmieri.com.
_Last updated: July 7, 2026._