When a POST Needs an Idempotency Key
Stripe's API documentation says it plainly: if a request might be retried, give it an idempotency key. That single sentence hides a large amount of engineering judgment. The key itself is a string, usually a UUID, that a client attaches to a POST request so the server can recognize a repeat and return the original result instead of creating a second record. The mechanism is simple. Knowing when a POST actually needs one is where teams get into trouble.
The short answer: any POST that creates or mutates server state in a way that a duplicate would make worse. The longer answer involves thinking about network failures, client retries, queue redeliveries, and the unhappy gap between what the client believes happened and what the server actually did.
The failure window that makes the key necessary
Consider a payment request. The client sends a POST to charge a card. The server processes it, writes a charge row, and calls the payment processor. Then the connection drops before the response reaches the client. From the client's side, the request failed. From the server's side, money moved.
The client does the sensible thing and retries. Without an idempotency key, the server sees a fresh request, creates a second charge, and the customer is billed twice. With a key, the server recognizes the retry as the same logical operation and returns the original charge object.
That gap, the moment between "the server committed" and "the client learned about it," is where idempotency keys earn their keep. It is a narrow window, but it opens constantly in distributed systems. Mobile clients on flaky cellular connections. Browsers that time out at thirty seconds. Job queues that redeliver a message when an acknowledgment is lost.
POST is not the problem; unsafe retries are
HTTP semantics treat POST as non-idempotent by default. GET, PUT, and DELETE are defined as idempotent, meaning a client can repeat them without changing the outcome beyond the first call. POST carries no such guarantee, which is why a POST that creates a resource, sends a message, or triggers a side effect is exactly the kind of request that needs help when it gets retried.
This is why idempotency keys show up most in payment APIs, order creation endpoints, and outbound notification services. The common thread running through all of them is that a duplicate has real consequences. A double charge is a refund and a support ticket. A double order is an inventory problem. A double email is an annoyed customer.
A POST that only reads data, or one whose effect is naturally reversible, can often skip the key. A request that logs an analytics event and can tolerate a duplicate is a candidate for skipping. A request that debits an account is not.
Where the key lives and how it is scoped
The key needs a home. It can travel in a header, usually something like Idempotency-Key, or inside the request body. Stripe uses a header. Other APIs bake it into the payload. Either works, but a header keeps the key out of the business logic and makes it easy for infrastructure like a gateway or a reverse proxy to see it.
Scoping matters more than placement. The server has to decide what "same key" means. The same key from two different customers should not collide. A reasonable scope is the key plus the authenticated account plus the endpoint. Two merchants can each send the UUID 6f1c... without interfering with one another.
There is also the question of what the key is attached to. The server stores the key alongside the result, usually for a limited window. Stripe's documentation describes a retention period of about 24 hours. After that window, a retry with the same key is treated as a new request. The retention window is a deliberate trade-off between storage cost and how long a client might plausibly retry.
What the server must store to honor the key
Honoring an idempotency key means the server has to remember enough about the original request to answer a retry without redoing the work. That means persisting the key, the response status, the response body, and enough metadata to detect a mismatched payload.
A subtle failure mode shows up here. Suppose a client sends a request with key K, gets a response, then sends a different request with the same key K. What should the server do? The safest behavior is to reject the second request with a conflict error, because the client has clearly made a mistake. Silently returning the first response to a different request would hide a bug.
This is why well-built idempotency layers store a hash of the request body and compare it on retry. If the hash matches, return the stored response. If the hash differs, raise an error. The comparison is cheap and catches a class of bugs that would otherwise corrupt state quietly.
Keys the client generates and keys the server generates
There are two patterns. In the client-generated pattern, the caller creates the key before the first attempt and reuses it across retries. This is the pattern most public APIs document, and it is the one that maps cleanly to the retry loop. The client picks a UUID, sends the request, and if it fails, sends the same request with the same UUID.
In the server-generated pattern, the server hands out a key that the client includes in a follow-up request. This is less common because it requires an extra round trip and a way to recover the key if the first response is lost. It shows up in some two-phase flows, but for ordinary create endpoints the client-generated pattern dominates.
The client-generated pattern has a trap. If the client crashes before persisting the key, the retry after restart will generate a new key and duplicate the operation. The key has to be durable on the client side before the request goes out. For a mobile app, that might mean writing to local storage before the network call. For a backend service, it might mean persisting the key in the same transaction that enqueues the request.
Retry storms and the storage bill
Idempotency keys are not free. Every key that arrives costs a write and, later, a read. On a high-volume API, that is a serious amount of storage and I/O. A service handling a few thousand requests per second accumulates millions of keys per day. Even at a modest retention window, the table grows fast.
The usual mitigation is a bounded retention window paired with an expiring store. Redis with a TTL, or a table with a scheduled cleanup job, does the job. The trade-off is that very late retries, the kind that arrive after a client has been offline for a day, will slip past the window and create duplicates. For most systems that is acceptable. For a payment system, the window is a number worth defending in a design review.
There is a subtler cost. During a retry storm, the same key arrives many times in quick succession. The server has to handle concurrent requests for the same key without racing. Two approaches work: a lock or an insert with a unique constraint. The unique constraint is simpler and usually correct. The first request inserts the key, the second hits a conflict, and the server either waits for the first to finish or returns a conflict response.
The endpoints that quietly need one
Payment charges are the obvious case, but they are not the only ones. Consider an endpoint that sends a one-time password by SMS. If the client retries after a timeout, the customer gets two messages. Worse, the second message might invalidate the first, and the code the customer is reading no longer works.
Consider an endpoint that provisions a cloud resource. A duplicate creates two instances, two bills, and a cleanup headache. Consider an endpoint that submits a form to a government portal, where the portal itself is not idempotent and a duplicate submission triggers a manual review.
These endpoints share a property: the client has a reasonable chance of retrying, and the server cannot easily tell a retry from a genuinely new request. Anything with a user on the other side of a slow network, or a queue with at-least-once delivery, qualifies.
When the key is the wrong tool
Idempotency keys do not fix a broken retry policy. A client that retries on every error, including 400s and 500s, will hammer a server and produce noise regardless of how well the keys work. The key protects the server's state. It does not protect the server's capacity.
They also do not help with requests that are not retried by the client but are duplicated by infrastructure. A message broker that delivers a message twice, or a webhook that fires twice because the sender's acknowledgment timed out, needs a different defense. In those cases the handler itself has to be idempotent, usually by checking whether the referenced record already exists.
And they do not help when the client cannot persist the key. A stateless function that generates a fresh UUID on every invocation and never stores it is not using idempotency keys; it is decorating requests with random strings and hoping.
A short test for whether a POST needs a key
Ask three questions. First, does a duplicate request change the world in a way that someone would notice and dislike? Second, can the client plausibly retry after a failure that the server did not see as a failure? Third, is the operation expensive enough or consequential enough that a manual reconciliation would be painful?
If two of the three are yes, add the key. If only the first is yes and the operation is trivially reversible, an idempotent handler that checks for duplicates by natural key might be lighter. If none are yes, the key is overhead.
The judgment is not about POST as a verb. It is about the blast radius of a repeat. A POST that creates a shopping cart line item is annoying to duplicate. A POST that charges a card is not. The key is a small, cheap insurance policy against the second kind of mistake, and the teams that treat it as a checkbox tend to discover the distinction when a customer calls about a double charge.

