Cross-post. Original: stellarbytecapital.com/blog/idempotent-api-design
Somewhere between your client and your server, a request will time out after the work was done but before the response came back. The client, seeing no answer, retries — and now you've charged the card twice, created two orders, or sent two transfers. Idempotency is the property that makes a retry safe: doing the same operation twice has the same effect as doing it once. For anything that moves money or creates records, it's not optional.
What "idempotent" really means here
GET, PUT, and DELETE are naturally idempotent — repeatable without additional effect. The problem is POST ("create a charge," "place an order"), which is not. Each call is meant to do something new, so a blind retry does the thing again. Idempotency keys make those unsafe POSTs safe to retry.
Idempotency isn't about the network never failing. It's about the operation staying correct when the network fails and the client retries — which it will.
The idempotency key pattern
The client generates a unique key per logical operation and sends it with the request; the server records that key with the operation's result, so a repeat of the same key returns the original result instead of doing the work again.
- Client generates a key (a UUID) once per logical intent, and reuses it on every retry of that intent. The key identifies "this specific charge," not "this HTTP attempt."
-
Server checks the key before doing the work. Unseen → process and store
(key → result). Seen → skip the work, return the stored result. - Same key, same response — the caller can't tell first attempt from fifth.
The details that make or break it
Store the key and result atomically with the work. The classic bug: do the work, then separately save the key, and crash in between — now the retry does it again. The key record and the effect must commit together, in one transaction.
Handle concurrent retries — they race. Two copies of the same request can arrive at once; if both check "is this key seen?" simultaneously, both see "no" and both process. Defend with a uniqueness constraint on the key: the first insert wins, the second fails and returns the stored/in-flight result. Reserve the key before doing the work, not after.
Represent the in-progress state. Record the key as pending at the start; concurrent callers with the same key wait or get "request in progress, retry shortly" until the first completes.
Scope keys correctly. A key is meaningful within a scope — usually per API account (often per endpoint). Two customers must be able to use the same random key without colliding. Scope the constraint to (account, key), and bind a fingerprint of the request body so a reused key with different parameters is rejected rather than silently returning the wrong old result.
Give keys a TTL. Keep them long enough to cover the retry window (hours to a day is typical), then expire. The store stays bounded.
Idempotency across services and events
Message queues usually guarantee at-least-once delivery, so consumers must dedupe on a message ID too. Outbound calls to third parties (a payment channel, an exchange) need their idempotency mechanism — attach a client-generated ID so your retry doesn't create a second real charge or order. Idempotency isn't one feature; it's a property you maintain at every hop where a retry can happen.
What to avoid
- Generating the key server-side — then the client can't send the same key on retry.
- Saving the key after the work, non-atomically — a crash in the gap reintroduces double execution.
- No uniqueness constraint — concurrent duplicates both slip through; the bug that survives light testing and fails in production.
- Ignoring the request body — a reused key with different parameters should error, not return a stale result.
- Keys that live forever — unbounded storage and stale semantics.
Idempotency is invisible when it works and catastrophic when it doesn't — nobody thanks you for the charge that wasn't duplicated. Get the pattern right once — client-owned keys, atomic key-plus-effect writes, a uniqueness constraint against races, correct scoping, a TTL — and every unsafe POST becomes safe to retry.
We're Xingyao Byte — building payment platforms, quant trading systems, secure AI-execution layers, and reliable backends. Remote, async-first → stellarbytecapital.com
Top comments (1)
You've provided a thorough breakdown of idempotency, especially the importance of atomic operations when storing the idempotency key and the result. It's a common pitfall to overlook the concurrency issues that can arise, so your emphasis on using uniqueness constraints is spot on. I also appreciate your suggestion on setting a TTL for keys; this can help manage storage effectively while maintaining reliability. If you're considering expanding the implementation or need assistance refining the concurrency handling, I'd be happy to discuss a paid collaboration to support those aspects. How have you approached testing these implementations in your projects?