Idempotency & Safety
Networks fail. Requests time out. Clients retry. If your API cannot handle the same request being sent twice, you will eventually charge a customer twice, create duplicate records, or send the same email three times. Idempotency and safety are not academic properties — they are the difference between a reliable API and a fragile one.
Safe Methods
A safe method does not change server state. Calling it zero times or a thousand times has the same effect on the server: none.
- GET — retrieve a resource or list
- HEAD — same as GET but returns only headers (no body)
- OPTIONS — returns the allowed methods for a resource
Safe methods are inherently cacheable and retriable. A client can call GET /users/123 as many times as it wants without worrying about side effects. If the request fails, retry it. If the response is slow, cache it. The server's state does not change.
# These are all safe — no side effects on the server
GET /orders/456
GET /orders?status=pending
HEAD /orders/456
OPTIONS /orders
The practical implication: GET requests should never modify data. This seems obvious, but it is violated more often than you would expect. APIs that delete resources on GET, or trigger emails on GET, break the fundamental contract that clients, caches, and intermediaries depend on.
Idempotent Methods
An idempotent method produces the same result whether you call it once or multiple times. The first call may change state, but subsequent identical calls do not change it further.
- GET — idempotent and safe (always returns the current state)
- PUT — idempotent (replacing a resource with the same data is a no-op)
- DELETE — idempotent (deleting an already-deleted resource returns 404, but the server state is the same)
- HEAD, OPTIONS — idempotent and safe
POST is neither safe nor idempotent. Calling POST /orders twice creates two orders. This is the root of most duplicate-request problems.
PATCH is not guaranteed idempotent. A PATCH that says "set name to Jane" is idempotent. A PATCH that says "increment counter by 1" is not.
PUT vs PATCH Idempotency
PUT replaces the entire resource. Sending the same PUT request twice results in the same resource state:
PUT /users/123
{"name": "Jane Doe", "email": "jane@example.com", "role": "admin"}
# Calling this again produces the same result — idempotent
PATCH updates specific fields. Whether it is idempotent depends on the operation:
// Idempotent PATCH — sets a value
{"name": "Jane Doe"}
// Non-idempotent PATCH — relative operation
{"balance_adjustment": 100}
The second PATCH adds 100 each time it is called. Design PATCH operations to be idempotent by using absolute values ("set balance to 500") instead of relative values ("add 100 to balance").
Why Idempotency Matters
Consider a payment API. A client sends a request to charge a customer:
POST /v1/charges
{"amount": 5000, "currency": "usd", "customer": "cus_abc"}
The server processes the charge, but the response is lost due to a network timeout. The client does not know if the charge succeeded. It has two choices:
- Do not retry — if the charge succeeded, good. If it failed, the customer is not charged but the order is not fulfilled.
- Retry — if the charge failed, this fixes it. If it succeeded, the customer is now charged twice.
Both options are bad. Idempotency keys solve this.
Idempotency Keys
An idempotency key is a unique identifier the client sends with a request. The server uses it to recognize duplicate requests and return the original response instead of processing the request again.
Stripe's implementation:
POST /v1/charges
Idempotency-Key: order-456-charge-attempt-1
Content-Type: application/x-www-form-urlencoded
amount=5000¤cy=usd&customer=cus_abc
The server's logic:
- Receive the request with idempotency key
order-456-charge-attempt-1 - Check if this key has been seen before
- If not: process the charge, store the response keyed by the idempotency key, return the response
- If yes: return the stored response without processing again
# First call — charge is created
POST /v1/charges
Idempotency-Key: order-456-charge-attempt-1
-> 201 Created {"id": "ch_xyz", "amount": 5000, "status": "succeeded"}
# Retry (same key) — stored response is returned
POST /v1/charges
Idempotency-Key: order-456-charge-attempt-1
-> 201 Created {"id": "ch_xyz", "amount": 5000, "status": "succeeded"}
# Different key — new charge is created
POST /v1/charges
Idempotency-Key: order-456-charge-attempt-2
-> 201 Created {"id": "ch_abc", "amount": 5000, "status": "succeeded"}
The client generates the key. A common pattern is combining the entity ID with the operation: order-456-charge-attempt-1. UUIDs also work but are harder to debug.
Implementing Idempotency Keys
Server-Side Storage
Store the idempotency key, the request fingerprint, and the response:
{
"key": "order-456-charge-attempt-1",
"request_hash": "sha256(method + path + body)",
"status_code": 201,
"response_body": "{\"id\": \"ch_xyz\", ...}",
"created_at": "2024-01-15T12:00:00Z"
}
Key implementation decisions:
- TTL — Stripe expires idempotency keys after 24 hours. This prevents the storage from growing unbounded while covering reasonable retry windows.
- Request matching — if the client sends the same key with a different request body, return a 422 error. The key is tied to a specific request.
- Concurrent requests — if two requests with the same key arrive simultaneously, one should succeed and the other should wait or return 409. Use database locks or atomic operations.
What to Store
Store the full HTTP response (status code, headers, body). The replay must be indistinguishable from the original response. If the original returned 201 with a Location header, the replay must return the same.
Error Responses
If the original request failed with a 4xx error, should the idempotency key store that failure? Yes. The client sent an invalid request — retrying the same invalid request should return the same error. The client needs to fix the request and use a new idempotency key.
If the original request failed with a 5xx error, the answer is less clear. Stripe does not store 5xx responses — they allow the client to retry with the same key, because the server error was not the client's fault.
Stripe's Idempotency Pattern in Detail
Stripe's approach is the industry reference:
- Every POST endpoint accepts an
Idempotency-Keyheader - Keys are scoped to the API key (two different Stripe accounts can use the same key string)
- Keys expire after 24 hours
- If the same key is sent with different parameters, Stripe returns a 400 error
- If the original request returned a 500, the key is released for retry
- If the request is still being processed (in-flight), subsequent requests with the same key return 409
# Request still processing
POST /v1/charges
Idempotency-Key: order-456-charge-attempt-1
-> 409 Conflict
{"error": {"message": "A request with this idempotency key is currently being processed."}}
This handles the edge case where a slow request triggers a retry before the original completes.
DELETE Idempotency
DELETE is idempotent by definition: deleting a resource that is already deleted does not change server state further. But what status code should you return?
Two common approaches:
# Approach 1: Always return 200/204 (Stripe does this)
DELETE /v1/customers/cus_abc
-> 200 OK {"id": "cus_abc", "deleted": true}
DELETE /v1/customers/cus_abc (already deleted)
-> 200 OK {"id": "cus_abc", "deleted": true}
# Approach 2: Return 404 on second call
DELETE /users/123
-> 204 No Content
DELETE /users/123 (already deleted)
-> 404 Not Found
Approach 1 is more client-friendly for retries. The client does not need to distinguish between "I deleted it" and "it was already deleted." Approach 2 is more semantically correct but requires clients to handle 404 as a success case for retries.
Making Non-Idempotent Operations Idempotent
Some operations are inherently non-idempotent but can be made safe with design choices:
Counter Increments
Instead of POST /accounts/123/increment {"amount": 100}, use a transaction resource:
POST /accounts/123/transactions
Idempotency-Key: txn-456
{"type": "credit", "amount": 100}
Now the operation is idempotent through the idempotency key. The same transaction is not applied twice.
Email Sending
Instead of POST /emails/send, create an email resource:
POST /emails
Idempotency-Key: welcome-email-user-789
{"to": "jane@example.com", "template": "welcome"}
The email is sent as a side effect of creation. If the request is retried, the existing email resource is returned and no duplicate is sent.
Common Pitfalls
- Side effects on GET — GET requests that trigger actions (sending emails, incrementing counters, modifying state). This violates HTTP safety and breaks caching, prefetching, and retry logic.
- Forgetting idempotency keys on payment endpoints — any endpoint that moves money or creates irreversible state changes needs idempotency support. This is non-negotiable.
- Client-generated keys without uniqueness — using sequential integers or short strings as idempotency keys. UUIDs or composite keys (entity ID + operation + timestamp) prevent collisions.
- No TTL on stored keys — idempotency key storage grows forever without expiration. 24 hours is a reasonable default for most APIs.
- Storing 5xx responses — if the server failed due to an internal error, the client should be able to retry with the same key. Do not permanently lock a key because of a server bug.
- Non-idempotent PATCH — using relative operations in PATCH (increment by 5, append to list) instead of absolute operations (set to 500, set list to [a, b, c]). Relative operations are dangerous on retry.
- Ignoring concurrent duplicates — two requests with the same idempotency key arriving at the same time. Without locking, both may be processed, defeating the purpose entirely.
Key Takeaways
- Safe methods (GET, HEAD, OPTIONS) do not change server state. They are always retriable and cacheable. Never put side effects in GET handlers.
- Idempotent methods (GET, PUT, DELETE) produce the same result when called multiple times. PUT replaces, DELETE removes, and both are safe to retry.
- POST is neither safe nor idempotent. Use idempotency keys to make POST requests safe to retry.
- Idempotency keys are a client-provided unique identifier that the server uses to detect and deduplicate retries. Stripe's pattern (24-hour TTL, scoped to API key, 409 for in-flight duplicates) is the industry standard.
- Design PATCH operations with absolute values, not relative ones. "Set balance to 500" is idempotent. "Add 100 to balance" is not.
- For any endpoint that creates charges, sends notifications, or produces irreversible effects, idempotency support is a requirement, not a feature.