
A payment endpoint times out after the customer clicks “Pay.” The client cannot tell whether the server failed before charging the card or charged it just before the response disappeared. It retries. Without idempotency, one transient network failure can become a duplicate charge, duplicate order, or duplicate provisioning request. Learning how to design idempotent APIs turns that ambiguity into a controlled, predictable outcome.
Idempotency is not just an HTTP-method detail. It is a contract between clients, API gateways, application code, databases, queues, and third-party services. Done well, it lets callers retry safely when networks, load balancers, and dependencies behave imperfectly – which they eventually will.
What Idempotency Means in an API
An operation is idempotent when making the same request multiple times has the same intended effect as making it once. The first request may create a resource, reserve inventory, or start a workflow. Repeating that same logical request should not repeat the side effect.
The phrase “same request” needs careful definition. Two requests may have identical JSON bodies but represent separate customer intentions. A customer who clicks “add $20 to my account” twice might genuinely expect two credits. Conversely, one request retried by a mobile app after a timeout must result in only one credit.
That distinction is why payload comparison alone is not enough. Idempotency requires an identifier that represents a single client intent.
HTTP semantics provide a useful starting point. GET, HEAD, PUT, and DELETE are conventionally idempotent, while POST is not assumed to be. But conventions do not guarantee behavior. A DELETE endpoint can still emit a new billing event on every call, and a PUT endpoint can create duplicate downstream messages if its implementation is careless. The server-side behavior is what matters.
How to Design Idempotent APIs Around Client Intent
For operations that create a new resource or trigger a meaningful side effect, accept an idempotency key. Clients generate a high-entropy value, usually a UUID, and send it in a request header such as `Idempotency-Key`.
The client must reuse the same key for every retry of the same logical action. For a new action, it creates a new key. The API stores the key alongside enough information to identify the request and its outcome.
A typical flow looks like this:
- The server receives a request with an idempotency key.
- It looks up that key within the correct scope, such as a tenant, account, or authenticated user.
- If a completed record exists and the payload matches, it returns the saved response rather than performing the work again.
- If no record exists, it claims the key, executes the operation, and stores the final response.
The scope is a security and correctness decision. A globally unique key is simple, but scoping by tenant or account prevents one caller from accidentally or maliciously replaying another caller’s key. The stored request should include a payload fingerprint, such as a cryptographic hash of canonicalized request fields, plus relevant route and API-version information.
If a client sends the same key with a different payload, return a clear conflict response, commonly HTTP 409. Silently treating a changed payload as a retry can apply the wrong business action. Returning a new result defeats the point of the key.
Store the Response, Not Only the Resource ID
Many implementations store only the created resource ID. That can work for simple endpoints, but storing the status code and response body is usually more reliable. A retry should receive the same practical outcome as the original call, including a `201 Created` response when that was the initial result.
This matters when responses contain generated values, validation details, or state captured at the time of execution. It also prevents a retry from observing a changed resource and receiving a response that no longer reflects the original operation.
Be deliberate about failures. If validation fails before any side effect occurs, clients should usually be able to correct the request and retry with a new key. If the server returns a 500 after work may have started, preserving the key and its eventual outcome is valuable. The hard case is a request that is still running. Return a response such as `409 Conflict` or `202 Accepted` with an operation status reference, rather than allowing a second worker to begin the same work.
Make the Database Claim Atomic
The most dangerous idempotency bug appears under concurrency. Two retries can arrive at nearly the same moment, both see no existing key, and both create the resource.
Solve this at the persistence layer, not with an in-memory lock. Create a table for idempotency records with a unique constraint on the key and its scope. The first request inserts a record with a `processing` status. Any concurrent request attempting the same insert receives a uniqueness violation and reads the existing record instead.
A simplified record might contain the tenant ID, idempotency key, request hash, status, response status, response payload, created timestamp, and expiration timestamp. Keep the processing state visible so another request can distinguish between a completed operation and one that remains in progress.
Transactions help, but they are not a universal answer. If the endpoint writes to your database and publishes an event to a message broker, a database transaction cannot automatically roll back the broker publish. Use the transactional outbox pattern: write the business change and an outbox event in the same database transaction, then publish the outbox record asynchronously. Consumers should also be idempotent, because most message systems deliver at least once.
Use Resource Identity Where It Fits
An idempotency key is ideal for create-style POST operations, but it is not the only design option. Sometimes the client can choose the resource identity. A request like `PUT /subscriptions/{clientGeneratedId}` naturally maps repeated calls to the same target resource.
This model works well when the resource has a stable business identifier and the API can define replacement behavior clearly. It is less suitable when identifiers must remain server-generated, when the action is not resource creation, or when a request initiates a complex workflow such as a transfer or deployment.
For updates, optimistic concurrency complements idempotency. Use ETags or version fields with `If-Match` to prevent a client from overwriting changes it has not seen. Idempotency prevents duplicate execution of one intent; concurrency control protects against conflicting intents. Production APIs often need both.
Treat External Side Effects as First-Class Risks
Creating a row in a database is manageable. Charging a card, sending an email, issuing a refund, or calling a cloud provider is harder because the dependency may complete its work while your service loses the response.
Pass a stable correlation or idempotency key to downstream providers whenever their API supports it. Store that provider request ID with your own idempotency record. If the upstream call times out, do not immediately issue another charge or provisioning request. First query the provider by the correlation ID, if possible, or wait for an asynchronous callback.
Email and notification systems need special care. A user may tolerate a delayed confirmation email, but not five identical messages. Model the notification as a separately idempotent command, keyed by the business event and template version. The same principle applies to event consumers: deduplicate using an event ID or enforce uniqueness on the business action they perform.
Set Clear Retention and Retry Rules
Idempotency records cannot live forever without cost. Choose a retention window based on client behavior and business risk. A checkout key may need to be retained for 24 hours or several days; a financial transfer may require a much longer audit trail. Expiring a record too soon reopens the duplicate-action risk for delayed mobile clients and queued retries.
Document retry expectations. Tell clients which status codes are retryable, how long to wait, and whether they must reuse the same key. Timeouts, 429 responses, and many 5xx errors are common retry candidates. A 400 validation error usually is not. Exponential backoff with jitter prevents synchronized clients from hammering a recovering service.
Observability is part of the design. Log the idempotency key, request correlation ID, decision path, and downstream reference IDs. Track duplicate-hit rates, in-progress conflicts, key-payload mismatches, and expired-key reuse. Those signals reveal whether clients are retrying correctly and whether an upstream dependency is generating instability.
Test the Failure Paths, Not Just the Happy Path
A unit test that calls an endpoint twice is a start, not proof. Test concurrent duplicate requests, process crashes after the database commit, lost responses after an external call, message redelivery, and retries after a deployment.
Also test the business meaning. Repeating a `POST /orders` request should return the original order. Sending two requests with different keys should create two orders, even if their payloads are identical. Reusing a key with altered line items should fail loudly. These cases keep an idempotency feature from becoming an accidental deduplication system.
The best idempotent APIs make failure boring. When a client cannot know whether its first request succeeded, it should have a safe next move: retry the same intent, receive a consistent result, and continue. That confidence is what lets distributed systems recover from ordinary network failures without turning them into customer-facing incidents.




