Skip to main content
API Design Mastery

Idempotency

Ravinder··5 min read
API DesignRESTGraphQLgRPCReliability
Share:
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:

  1. Stores the key with status processing.
  2. Executes the operation.
  3. Stores the result against the key.
  4. Returns the result.

On retry with the same key:

  1. Finds the stored key.
  2. Returns the stored result immediately — no re-execution.
sequenceDiagram participant Client participant API participant DB participant PaymentProvider Client->>API: POST /payments (Idempotency-Key: idem_A) API->>DB: Check idem_A → not found API->>DB: Store idem_A status=processing API->>PaymentProvider: Charge $49.99 PaymentProvider-->>API: charge_id=ch_123 API->>DB: Store idem_A result={charge_id: ch_123} API-->>Client: 201 Created {charge_id: ch_123} Note over Client: Network timeout — client retries Client->>API: POST /payments (Idempotency-Key: idem_A) API->>DB: Check idem_A → found, result={charge_id: ch_123} API-->>Client: 200 OK {charge_id: ch_123} [replayed]

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 creation

Outbox 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.

flowchart LR A[POST /orders] --> B[DB Transaction] B --> C[(orders table)] B --> D[(outbox table)] D --> E[Outbox Worker] E --> F[Payment Service] E --> G[Inventory Service] E --> H[Email Service]

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-Key header; 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: true header 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.
Share: