The Role of Idempotency in API Design
Idempotency keys, safe retries, the difference between idempotent and safe verbs. Why payments APIs care most.
Summary#
An operation is idempotent if applying it N times produces the same observable state as applying it once. “Set the user’s email to alice@example.com” is idempotent — the second call changes nothing. “Charge 50.
Idempotency is the property that makes retries safe. Networks lose packets. Servers crash mid-write. Clients time out at 5 seconds while the server actually finished at 6. Without idempotency, every one of those moments forces the client into an impossible choice: retry (and risk a duplicate write) or give up (and risk a lost write). With idempotency — either at the HTTP-verb level or via an explicit idempotency key — the client retries freely and the server makes the right thing happen.
The HTTP method spec assigns idempotency by verb: GET, PUT, DELETE, HEAD, OPTIONS are idempotent; POST and PATCH are not. That’s the protocol-level promise. The application-level reality is more nuanced — a well-designed API makes its non-idempotent verbs (notably POST) idempotent through idempotency keys, a pattern Stripe formalised and the rest of the industry adopted.
Idempotency is most load-bearing in payments. A double-charge that costs the customer $50 and the merchant a chargeback is the canonical failure mode. But the principle generalises to any API where the side-effects are visible and the network is real.
Why it matters#
Three reasons idempotency is a first-class concern in API design:
- Networks fail in the worst possible way. They do not fail by dropping requests cleanly. They fail by succeeding ambiguously — the request reached the server, the server processed it, the response was lost on the way back. The client cannot tell “did it commit?” from “did it never arrive?” Idempotency is the property that makes the client not need to tell — retrying either case lands at the same state.
- Retries are mandatory at scale. Every production API client retries on connection errors and 5xx responses. Most retry policies have exponential backoff and jitter (see
managing-retries). Without idempotency, those retries cause duplicate writes; with idempotency, they merge transparently. There is no version of “we don’t retry” that survives contact with production. - Idempotency is a contract, not an implementation detail. Whether
POST /chargesis safe to retry is a documented property of your API. Clients read it from the docs and design their behaviour around it. Changing it later breaks every integrator that retried freely. Build it in on day one.
The senior-signal phrasing in an interview: “Every write endpoint is either idempotent by design, or accepts an idempotency key. Otherwise, the retry contract is undefined and we’ll get duplicate writes in production within the first week.”
How it works#
The HTTP-verb taxonomy#
The HTTP/1.1 spec (RFC 9110) classifies methods by two orthogonal properties: safe (no side-effects) and idempotent (repeatable).
| Method | Safe | Idempotent | Why |
|---|---|---|---|
GET | yes | yes | Read-only; reading twice gives the same answer. |
HEAD | yes | yes | Like GET, just no body. |
OPTIONS | yes | yes | Returns capabilities; no state change. |
PUT | no | yes | ”Set the resource to this state” — second call is a no-op. |
DELETE | no | yes | ”Remove the resource” — second call finds it already gone. |
POST | no | no | ”Create something new” — second call creates another one. |
PATCH | no | no | ”Apply this delta” — second call applies it again. |
Two clarifications the table glosses over:
- Safe implies idempotent, but not vice versa. A safe operation has no observable side-effect, so repeating it is by definition the same as doing it once. The converse is not true —
DELETEis idempotent but not safe (it has a side-effect; just not a different one on the second call). - “Idempotent” means the observable state, not every byte of the response.
DELETE /orders/42returns204 No Contentthe first time and404 Not Foundthe second time. The status codes differ; the resulting state of the resource is identical. That’s still idempotent.
A well-designed API treats the table as a contract. POST /orders creates a new order; calling it twice creates two orders. PUT /users/42 sets user 42’s state; calling it twice gives the same user 42. Violating the protocol — making GET mutate state, making POST non-create — is the kind of bug that breaks every cache and every retry policy downstream.
Idempotency keys — making POST idempotent#
For the operations that genuinely cannot be PUT (because they create a new resource whose ID the server picks), the industry pattern is the idempotency key: a client-generated unique string sent in a header. The server stores the key, deduplicates retries against it, and returns the original response on a replay.
Stripe formalised this in 2015 with the Idempotency-Key header; AWS, Square, PayPal, Adyen, and most modern payments APIs followed. The pattern is informal (no RFC) but operationally universal.
POST /v1/charges HTTP/1.1Host: api.stripe.comAuthorization: Bearer sk_test_...Idempotency-Key: chg_attempt_a3f9c2_2026-05-30T08:14:23ZContent-Type: application/json
{ "amount": 4999, "currency": "usd", "source": "tok_visa"}The client picks the key. A typical choice: a UUID generated when the user clicks “Pay”, reused across all retries of that specific button-click. If the user clicks “Pay” again, that’s a new attempt with a new key.
Server-side flow:
1. Receive request with Idempotency-Key: K 2. Look up K in the idempotency store. ├── Not seen → process request, store (K → response), return response ├── Seen, in-flight → return 409 Conflict or wait briefly └── Seen, complete → return stored response (do NOT re-process)The pattern has three engineering decisions hiding inside it:
- Storage — Redis or a database table. Redis is fast and TTL-friendly; a DB table is durable and queryable. Stripe uses a DB-backed store with a 24-hour TTL. The store must survive process restarts; otherwise an in-flight retry after a crash creates a duplicate.
- Scope — keys are scoped per API key (one customer’s keys don’t collide with another’s) and often per endpoint (a
POST /chargeskey doesn’t dedupe aPOST /refunds). - Request-body binding — the stored entry includes a hash of the request body. If the client sends the same key with a different body, that is a client bug — return
400 Bad Requestrather than serve the first response. Stripe returns an error in that case.
The retry-after-timeout pattern#
The canonical case for idempotency keys: the client times out at 5 seconds, but the server actually finished the charge at 6 seconds. Without an idempotency key, the client must decide: retry (risk double charge) or treat as failed (risk the customer thinking it didn’t work and clicking again). With an idempotency key, the retry path is unambiguous.
Client Server │ │ │ POST /charges │ │ Idempotency-Key: K │ │ amount: 4999 │ │────────────────────────────────────►│ │ │ │ │ Process charge (slow) │ │ │ timeout after 5s │ │ X │ │ │ Charge succeeds at 6s │ │ Store (K → "200 charge ch_42 created") │ │ │ │ Response lost (client already gone) │ │ │ POST /charges (retry) │ │ Idempotency-Key: K ← same key │ │ amount: 4999 │ │────────────────────────────────────►│ │ │ Look up K → seen, complete │ │ Return stored response │ │ │ 200 OK, ch_42 │ │◄────────────────────────────────────│The client gets the same ch_42 it would have gotten on the first try. The charge happened once. The customer got charged once. The merchant got a single record.
Idempotency for PUT and DELETE — automatic#
PUT /users/42 is idempotent by definition: “set user 42’s state to this.” Two identical PUT calls leave user 42 in the same state. The client can retry freely; the server doesn’t need an idempotency key for the verb-level promise.
DELETE /orders/42 is idempotent: “remove order 42.” Two calls leave order 42 deleted. The second one might 404 (some APIs) or return 204 again (others, treating “already deleted” as “successfully deleted”). Either is valid; the observable state is the same.
The implementation foot-gun: a PUT that does “set state to this” is idempotent; a PUT that does “increment counter” is not idempotent (despite using PUT). Putting non-idempotent semantics behind an idempotent verb is a contract violation. Use POST with an idempotency key for non-idempotent semantics, regardless of the URL shape.
Idempotent vs safe — the sub-distinction#
| Verb | Safe | Idempotent | Example |
|---|---|---|---|
GET /orders/42 | yes | yes | Read; no side-effects. |
PUT /orders/42 | no | yes | Set state; idempotent but mutates. |
DELETE /orders/42 | no | yes | Remove; idempotent but mutates. |
POST /orders | no | no | Create; each call makes a new one. Add idempotency key. |
The practical rule: safe operations need no defence against retry. Idempotent-but-not-safe operations are retry-safe by verb. Non-idempotent operations need an idempotency key.
Storage choices for the key store#
Three patterns appear in production:
- Redis with TTL. Fast, simple, eventually-consistent. Set the response with
SETEX idempotency:<key> 86400 <serialised-response>. Works for most APIs. Risk: a Redis failover can lose keys mid-flight. - Database table with TTL job. Durable, transactional, slower. The idempotency store is the same database that holds the resource. Stripe uses this pattern; the transaction that creates the charge and the row in the idempotency table commit together.
- Stream-based deduplication. For event-driven APIs, the dedupe is at the consumer (Kafka with idempotent producer + transactional consumer; SQS with message-deduplication-id). Same conceptual pattern; different implementation surface.
The transactional version (DB + same transaction) is the safest. If the row in the idempotency table commits, the resource was created; if either fails, both fail. No window where the resource exists but the key wasn’t stored.
Where the pattern shows up beyond payments#
Payments is the canonical use case because the cost of a duplicate is obvious (money). The same pattern matters elsewhere:
- Order creation — a duplicate order ships duplicate inventory and racks up duplicate fulfilment cost.
- Notification sending — a duplicate push notification is annoying; a duplicate email is spam.
- Webhook delivery — webhooks are inherently at-least-once. The receiving API must dedupe on the
event_idfrom the webhook payload. - External-API calls — anything you call that has side-effects and might time out (SMS providers, shipping carriers, payment processors). Wrap your client in idempotency-key generation.
- Background job dispatch — enqueueing a job twice runs it twice; jobs that send email or charge cards need idempotency keys, not just retries.
Variants and trade-offs#
Client-generated idempotency keys. Client picks the key (typically a UUID per logical attempt) and sends it on every retry of that attempt. The server is purely a dedupe store. Pattern: Stripe, AWS, Square. Pros: client controls semantics. Cons: client must implement it.
Server-generated dedupe via request fingerprint. Server hashes the request body + source + a recent timestamp and dedupes against that. No client cooperation needed. Pros: works for naive clients. Cons: false-deduplication risk (intentional rapid-fire duplicates get merged); time-window-dependent.
| Decision | Choice A | Choice B |
|---|---|---|
| Key source | Client UUID | Server-computed hash |
| Storage | Redis (TTL, fast) | DB (durable, transactional) |
| TTL | 24h (Stripe default) | 7d (longer for slow flows) |
| Conflict on different body | 400 error | Quietly serve original (dangerous) |
| Header name | Idempotency-Key (industry-default) | Custom (avoid) |
| Required vs optional | Required for writes (strict) | Optional, fail-open (permissive) |
The senior choice is almost always: client-generated UUIDs, DB-backed durable storage, 24h TTL, return error on body mismatch, Idempotency-Key header by name, required on all write endpoints. That’s the Stripe playbook and it works.
When this is asked in interviews#
Idempotency comes up in three places in API-design interviews:
- As the immediate follow-up to “how do you handle retries?” The shallow answer is “exponential backoff with jitter.” The senior answer adds: “and the endpoint is idempotent — either by verb or with an idempotency key — so the retry is safe.”
- In any payments-flavoured design (Stripe, banking, ticketing). The interviewer will set up a network-flakes-mid-charge scenario and ask what happens. The answer is the retry-after-timeout pattern, with the idempotency key flowing end-to-end.
- In any event-driven design. Webhooks are at-least-once; consumers must dedupe; the dedupe is conceptually the same as idempotency-key handling.
Specific points to make in any of those contexts:
- Name the HTTP-verb classification (GET/PUT/DELETE idempotent; POST/PATCH not). Acknowledge that safe ⊂ idempotent.
- Name the
Idempotency-Keyheader specifically. Reference Stripe as the canonical implementation. - Specify the storage (Redis or DB), the TTL (24h is the industry default), and the behaviour on body mismatch (return 400, do not silently serve the stored response).
- Specify the scope (per API key, optionally per endpoint).
- Tie it to the retry policy — idempotency is the property that makes the retry policy safe. Without idempotency, retries are damage.
The strongest one-liner: “Every write endpoint is idempotent — either by HTTP verb or via an Idempotency-Key header — so the client’s retry policy is safe.”
Related concepts#
- Managing Retries — the client-side discipline that makes idempotency necessary; exponential backoff, jitter, circuit-breaker integration.
- HTTP — The Foundational Protocol for APIs — the verb-level idempotency contract from RFC 9110.
- Design the Stripe Payment API — the canonical implementation; idempotency-keys end-to-end.
- REST — The Architectural Style — how the verb-level idempotency promise shapes RESTful design.
- API Monitoring — detecting duplicate writes that escape the idempotency layer is itself a monitoring concern.