Idempotency Keys for APIs: The Complete Guide
Every API client eventually runs into the same failure: a request goes out, the connection drops before a response comes back, and the caller has no idea whether the operation actually happened on the server. Retry, and you might charge a customer twice or create a duplicate order. Don't retry, and you might leave a legitimate request unfulfilled.
Idempotency keys exist to close that gap, and they've become one of the most practical, low-ceremony patterns covered in our API fundamentals hub.
What Is an Idempotency Key?
An idempotency key is a unique value, generated by the client and sent with a request, that lets the server recognize when it has already processed that exact operation. If the same key arrives again, the server skips re-execution and returns the original response instead. The key is usually carried in an Idempotency-Key request header on POST or PATCH calls, the two HTTP methods that aren't idempotent by default.
In practice, this means a client can safely resend a request it's unsure about, with no risk of the server performing the underlying action more than once.
Why Idempotency Matters for APIs
Networks fail in ways the client can't see. A request can succeed on the server while its response is lost in transit, a load balancer can time out a connection mid-flight, or a mobile client can lose signal while waiting for a reply. Each time, the client is left holding an unanswered request and a decision: retry, or don't.
Here's a concrete version. A client sends a request, and the server receives it, processes it, and commits the change, but the connection drops before the response gets back. From the client's side, this looks exactly the same as the server never receiving the request at all. The timeout says nothing about which of the two happened.
Without an idempotency mechanism, neither choice is safe. Retrying a payment, an order, or an account signup can produce a duplicate charge, order, or account. Not retrying risks silently dropping a request the server never received.
Idempotency keys narrow that gap, but the guarantee they provide is bounded, not absolute. As long as the client reuses the same key and the server still holds a record of it, a retry replays the original result instead of executing again. That only holds while the record exists. Once a key expires or is evicted from storage, a reused key is treated as a brand-new request, so "at most once" really means "at most once, within the retention window."
Idempotent vs. Non-Idempotent HTTP Methods
HTTP already defines idempotency for some methods, and it's worth being precise about what that means before adding a key to the mix. GET, HEAD, PUT, DELETE, and OPTIONS are idempotent by specification: sending the same request several times should leave the server in the same state as sending it once. A PUT that sets a resource's name to "Invoice-042" produces the same end state whether it's sent once or five times.
POST and PATCH don't have that guarantee by default. RFC 5789, which defines the PATCH method, says plainly that PATCH is neither safe nor idempotent. A POST to /orders creates a new resource on every call, so five identical requests can create five separate orders. A PATCH that increments a counter or appends to a list changes the result every time it's applied, even when the request body stays the same.
The same RFC notes that a PATCH request can be designed to be idempotent. A "set this field to this value" patch behaves that way naturally, so the guarantee depends on how the operation is defined, not just on the method name. Idempotency keys cover the general case: they bring POST and PATCH into an idempotent-like state without changing what those methods mean or requiring every endpoint to be idempotent by design.
How Idempotency Keys Work
The mechanics are the same across most implementations, even though header names and storage details vary:
- The client generates a unique key, usually a UUID, and attaches it to the request.
- The server checks whether it has seen that key before.
- If it hasn't, the server processes the request normally and stores the key alongside the response.
- If it has, the server skips re-execution and returns the stored response, as though the request had just been handled.
The Idempotency-Key Header
An Idempotency-Key request header has become a de facto convention across API providers, and MDN documents it as a way to make POST and PATCH requests idempotent.
It's worth being clear about its status. MDN marks the header as experimental and explicitly non-standard, and its specification, the IETF Internet-Draft "The Idempotency-Key HTTP Header Field", has expired at the IETF rather than progressing to a finished RFC. That hasn't stopped it from becoming a widely adopted server-side convention, because it's an ordinary application-level header, not a browser feature. It isn't on the Fetch standard's forbidden-header list, so any client can attach it to a request, whatever a given browser implements natively.
The real browser-side consideration is CORS. Idempotency-Key isn't one of the small set of CORS-safelisted request headers, so a cross-origin call that carries it triggers a preflight OPTIONS request, and the server has to allow it explicitly through Access-Control-Allow-Headers. For API design, the pattern matters more than the exact header name a particular provider chooses.
According to the draft, the server is expected to check both the key and, optionally, an "idempotency fingerprint," a hash of the request itself, so that reusing a key with a different payload is treated as an error rather than silently accepted. The draft also defines the errors a server should return:
400if a required key is missing.409if a request with the same key is still being processed.422if the key has been reused with a payload that doesn't match the original.
None of that depends on the draft being an active or ratified standard. It's simply the convention most implementations already follow.

Server-Side Storage and Expiration
Storing idempotency keys is more a caching problem than anything else. A common pattern is a key-value store, often Redis or a dedicated database table, keyed on the idempotency key and holding the request fingerprint, the response status code and body, and a timestamp.
Because the whole point is to protect against retries that happen shortly after the original request, most implementations expire keys after a bounded window instead of keeping them forever. A 24-hour retention window is common among public API providers. It's long enough to cover realistic retry scenarios without piling up an unbounded amount of historical response data.
Implementing Idempotency Keys: A Practical Walkthrough
A naive implementation checks whether a key exists and only afterwards writes a record for it. That ordering has a race condition built in: two identical requests arriving close together can both pass the "does this key exist?" check before either has written anything, and both go on to execute.
The fix is to claim the key atomically, with an insert that succeeds once and fails for every later request, before the operation runs at all:
on POST or PATCH request:
caller = authenticate_and_authorize(request)
key = request.headers["Idempotency-Key"]
if key is required and missing:
return 400
scope = (caller.tenant_id, caller.id, operation_name, key)
fingerprint = hash(request.body)
claim = store.try_insert(scope, fingerprint, status="processing")
if claim.already_existed:
if claim.fingerprint != fingerprint:
return 422 # same key, different request body
if claim.status == "processing":
return 409 # original request is still in flight
return claim.response # replay the stored result for this same caller
response = process(request)
store.complete(scope, status="completed", response=response)
return response
Two things in that sketch matter beyond the happy path.
First, the scope includes the caller and tenant, not just the key. An idempotency key that isn't scoped to whoever created it can let a guessed or reused key retrieve someone else's cached response. That's why the sketch authenticates and authorizes the caller before doing anything with the key, so a stored result can only ever be replayed to the caller it belongs to.
Second, try_insert is doing real work. On a relational database, it's usually a unique constraint on (tenant_id, caller_id, operation_name, idempotency_key), used with an insert that either creates the row or reports a conflict without creating a duplicate. In Postgres, that looks like this:
INSERT INTO idempotency_records
(tenant_id, caller_id, operation_name, idempotency_key, fingerprint, status)
VALUES ($1, $2, $3, $4, $5, 'processing')
ON CONFLICT (tenant_id, caller_id, operation_name, idempotency_key) DO NOTHING
RETURNING id;
If a row comes back, this request owns the operation and should run it. If no row comes back, another request has already claimed the key, and the code above decides what to do based on that record's status and fingerprint.
That handles the race between two requests. It doesn't, on its own, handle a system failure between executing the side effect and saving its record. If the business mutation is a write to the same database, run it and the store.complete call in the same transaction as the claim, so they either both commit or both roll back. That matches the atomicity guidance Amazon's engineering team gives in its Builders' Library article on idempotent retries.
If the mutation instead calls an external system, such as a payment processor, a local transaction can't cover the remote effect. The process can crash after the payment succeeds but before the local record is written, and no amount of database atomicity fixes that, because the side effect has already happened outside the database. That case needs the same idempotency key passed on to the downstream system (most payment APIs accept one for exactly this reason), plus a reconciliation job that can detect and resolve the mismatch. An "at most once" claim can't rest on a transient local cache alone.

Handling Concurrent Requests with the Same Key
The 409 Conflict response in the sketch is what a second request receives when it arrives while the first is still being processed. It tells the client to wait and retry, rather than assume failure and generate a new key. It's a direct result of claiming the key before running the operation instead of after: a request that loses the race finds a row that already exists, in a processing state it can't safely act on.
Choosing a Good Idempotency Key
The key should be generated by the client, not the server, since the whole point is that the client controls retries. A version 4 UUID is the standard choice: it has enough entropy that accidental collisions between unrelated requests are effectively impossible, and it needs no coordination between clients. Avoid keys derived from predictable or reused values, such as a fixed string per endpoint, because that makes unrelated requests collide on the same key and defeats the purpose.
Common Implementation Mistakes
A handful of mistakes account for most of the idempotency bugs seen in production APIs:
- Reusing a key across different request bodies. If a client resends a key with a changed payload, the server needs to detect the mismatch and reject the request, rather than silently returning a stale response for a different operation.
- No expiration policy. Storing every key forever turns a small cache into an unbounded table. Set a TTL that comfortably covers realistic retry windows, and let the store evict anything older.
- Treating idempotency as a substitute for error handling. A key prevents duplicate execution. It does nothing for a request that was malformed, unauthorized, or invalid to begin with, which still needs its own validation and error responses.
- Skipping atomicity in the check-and-store step. This reopens the race condition described above.
- Assuming a database transaction covers a remote side effect. A transaction around the idempotency record and a local write only guarantees at-most-once execution for that local change. If the operation also calls an external system, a crash between that call succeeding and the local record being written can't be fixed with a bigger transaction, because the remote effect has already happened outside it.
- Leaving the key unscoped to the caller. Without scoping a record to the caller or tenant that created it, a guessed or reused key can return someone else's cached response. Authenticate the caller before replaying a stored result, not just on the original request.
Idempotency Keys in Practice: Real-World Examples
Payments are the classic use case, because a duplicate charge is a direct, measurable cost to a customer, not just an inconvenience.
Stripe's documented idempotency layer saves the status code and body of the first request made with a given key, including error responses, and returns that same result for every later request with the key. The guarantee is bounded, like any retention-based cache: Stripe removes keys automatically once they're at least 24 hours old, and a key reused after the original has been pruned simply starts a new request instead of replaying anything. Stripe also doesn't save a result if the incoming request fails validation or collides with another request for the same key that's still running. Since no endpoint actually started executing in either case, those requests can simply be retried. Stripe recommends generating keys as UUIDs, in line with the general guidance above.
If a browser-based client calls your API through WSO2 API Platform, the gateway is also where the CORS preflight issue mentioned earlier gets handled. The platform's CORS policy lets you list Idempotency-Key in Access-Control-Allow-Headers, so cross-origin retries carrying the key aren't rejected before they reach the backend. The deduplication itself still belongs in the service that owns the write, as described above.
Idempotency Keys for AI Agents and Automated Retries
Idempotency keys were designed with a particular failure in mind: a human-driven client, or code written by a human, retrying after an ambiguous network error. AI agents introduce a different version of the same problem, at a different scale.
An agent calling an API to complete a multi-step task may retry a slow-to-confirm write as a matter of course, just as it would retry a read, unless the orchestration layer around it is specifically built to prevent that. WSO2's Nuwan Dias, VP and Deputy CTO, makes this point directly in a post on API design for AI agents: an agent that retries a slow-to-confirm write without an idempotency safeguard can produce exactly the duplicate charge that idempotency keys were built to prevent.
The server-side mechanism doesn't change. Something still generates a unique key for each intended write, stores it alongside the request, and returns the cached result if the same key arrives again. What has to change is upstream, in how reliably the calling layer holds on to that key.
The key needs to be persisted wherever the retry loop actually lives, whether that's the agent's own state or a tool-calling layer in front of it, and reused for every retry or resumption of the same logical write. Generating a fresh UUID each time the model repeats a tool call defeats deduplication entirely: the server sees what looks like a new request every time and processes it again. That's a bug in the orchestration layer, not a limitation of the idempotency-key pattern. It applies whether the agent calls a REST endpoint directly or goes through a tool-calling layer built on top of one, because the underlying API still has to provide the same at-most-once guarantee.
This design question is big enough to have its own emerging body of guidance. WSO2's Agent-ready APIs content hub, a set of guides on designing APIs that AI agents can discover and use reliably, lists idempotent tool design as one of its core topics, alongside permission scoping and agent observability. That reflects how central the pattern has become to agent-safe API design.
Idempotency Key Best Practices Checklist
- Require an
Idempotency-Key(or equivalent header) onPOSTorPATCHoperations whose side effects are worth deduplicating, such as payments, orders, or account creation. This is an API policy decision, not an HTTP rule, and rejecting a missing key on those endpoints with a400is reasonable. - Generate keys on the client as UUIDs; never derive them from predictable or reused values.
- Claim the key atomically, before the operation runs, rather than checking for it and writing it in two separate steps.
- Scope every idempotency record to the caller or tenant and the operation, and authenticate the caller before replaying a stored response.
- Store a fingerprint of the request body alongside the key, and reject reuse of a key with a different payload.
- Set an explicit TTL on stored keys instead of keeping them forever.
- For operations with an external side effect, such as a downstream payment call, don't rely on a local transaction alone. Pass the same key downstream and add a reconciliation path for the crash-in-between case.
- Document the header name, retention window, and error behavior, so client developers, human or automated, can implement against them correctly.
- For agent-facing or automation-heavy endpoints, persist the key across retries and resumptions of the same logical write instead of generating a new one for each attempt.
Conclusion
Idempotency keys solve a narrow problem well: they make retrying a write safe, without the client needing to know whether its previous attempt succeeded. The pattern isn't free. It adds a storage layer and a fingerprint check and, for anything beyond a single local database write, a real design decision about how far the at-most-once guarantee extends and who is allowed to see the stored results.
That cost is worth paying for operations with a genuine side effect, such as payments, orders, and account creation. And it matters more, not less, as more of an API's traffic comes from automated retries and AI agents rather than people clicking through a UI. For a deeper look at what agent-driven traffic means for API design, see WSO2's guides on building agent-ready APIs.
Frequently Asked Questions
Is idempotency good or bad?
Idempotency is a design property worth building toward, not a trade-off to weigh. Making a write operation safe to retry removes a whole class of duplicate-execution bugs without meaningfully limiting what the API can do.
Should an idempotency key be required?
For any POST or PATCH operation with a real side effect, such as payments, orders, or account creation, requiring the key and rejecting requests without one is safer than treating it as optional.
What is idempotency in security?
It isn't a replay defense in the security sense. An attacker who can submit an authorized request can simply generate a new key and run the operation again. What idempotency limits is accidental duplication: a legitimate retry, a client bug, or a network hiccup gets deduplicated instead of executed twice. Authentication, authorization, and dedicated anti-replay controls still do the actual security work.
What does it mean when an API is idempotent?
It means repeating the same request produces the same server-side result as sending it once. GET, PUT, and DELETE are idempotent by HTTP specification, while POST and PATCH need an explicit mechanism, such as an idempotency key, to get the same guarantee.