Idempotency
Networks lie. Timeouts happen. Clients retry. When a payment request times out at 29 seconds, the client cannot tell whether the server processed the charge or not. If the retry is not idempotent, the customer gets charged twice and your support queue fills up. Idempotency is what lets clients safely say "I don't know what happened — try again" without duplicating business state. It is one of the most underappreciated reliability features an API can offer.
HTTP Idempotency by Method
Some HTTP methods are idempotent by definition:
| Method | Idempotent | Safe |
|---|---|---|
| GET | Yes | Yes |
| HEAD | Yes | Yes |
| PUT | Yes | No |
| DELETE | Yes | No |
| PATCH | No | No |
| POST | No | No |
GET and HEAD have no side effects. PUT and DELETE are idempotent: calling PUT /orders/42 with the same body twice produces the same state. POST and PATCH are not — each call may create a new resource or apply a delta.
The danger zone is POST: creating orders, payments, subscriptions. These are exactly the operations that must survive retries safely.
The Idempotency Key Pattern
The solution: let the client generate a unique key per logical operation. The server stores the result keyed by that value. On retry, the server returns the stored result instead of re-executing.
POST /payments HTTP/1.1
Content-Type: application/json
Idempotency-Key: idem_01J8K2MNPQ3RS4TUV5WX6YZ7A
{
"amount": 4999,
"currency": "USD",
"customerId": "cust_123",
"orderId": "ord_456"
}On first receipt, the server:
- Stores the key with status
processing. - Executes the operation.
- Stores the result against the key.
- Returns the result.
On retry with the same key:
- Finds the stored key.
- Returns the stored result immediately — no re-execution.
Key Generation and Validation
Clients should generate keys using UUID v4 or a ULID:
const idempotencyKey = crypto.randomUUID(); // browser / Node.js
// idem_01J8K2MNPQ3RS4TUV5WX6YZ7A (ULID with prefix)Server-side validation rules:
- Reject missing keys on non-idempotent endpoints (return 400 with a clear error).
- If the same key arrives with a different request body, return 422 (the key was already used for a different operation).
- Expire keys after a reasonable window (24 hours to 7 days depending on your retry policy).
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/idempotency-key-mismatch",
"title": "Idempotency Key Mismatch",
"status": 422,
"detail": "The idempotency key 'idem_A' was previously used with a different request body."
}Partial Failure Semantics
Idempotency gets complicated when an operation has multiple steps and fails partway through:
Step 1: Reserve inventory ✓
Step 2: Charge payment ✓
Step 3: Create shipment ✗ (timeout)On retry with the same idempotency key, should steps 1 and 2 re-execute? No — they already succeeded and must not be repeated. The server must track per-step completion state.
Two patterns:
Saga with idempotent steps: Each step is idempotent in isolation. The saga coordinator retries only failed steps using per-step idempotency keys derived from the root key:
idem_A → root key for the order
idem_A:reserve → inventory reservation
idem_A:charge → payment charge
idem_A:ship → shipment creationOutbox pattern: Write all side effects to a transactional outbox in the same database transaction as the business state update. A background worker processes the outbox and delivers effects exactly once.
Idempotency in PATCH
PATCH is not inherently idempotent, but you can make it so with conditional requests:
PATCH /orders/42 HTTP/1.1
Content-Type: application/json
If-Match: "etag-abc123"
{"status": "cancelled"}The server only applies the patch if the resource's ETag matches. If the resource changed between the client's read and this write, the server returns 412 Preconditioned Failed. This is optimistic locking — the client must re-read and decide whether to retry.
Signaling Replayed Responses
Distinguish a fresh response from a replay so clients can distinguish creation from replay in logs:
HTTP/1.1 200 OK
Content-Type: application/json
Idempotency-Replay: true
Original-Request-At: 2025-11-05T09:00:00Z
{
"chargeId": "ch_123",
"status": "succeeded"
}Return 200 (not 201) for replayed responses. The 201 status is reserved for the original creation.
Key Takeaways
- POST and PATCH are not idempotent by default — any operation with side effects (payments, provisioning, messaging) must be made safe for retries explicitly.
- Use a client-generated
Idempotency-Keyheader; the server stores the result and replays it on duplicate requests within the key's TTL. - Reject keys that arrive with mismatched request bodies — this is a bug in the client, not a retry.
- Model partial failure with per-step idempotency keys derived from a root key, or use the outbox pattern for transactional consistency.
- Return 200 (not 201) for replayed responses and set an
Idempotency-Replay: trueheader so clients and logging infrastructure can distinguish creation from replay. - Expire idempotency keys after a defined window aligned to your longest reasonable retry interval; never store them forever.