Idempotency Keys: Making Retries Safe
Here's a question with no good answer: your client sends POST /charges, and
the connection times out. Did the charge go through?
You don't know. The request might have failed before reaching the server — safe to retry. Or it might have succeeded, with only the response lost on the way back — in which case retrying charges the customer twice. At-least-once delivery is the only guarantee the network offers, and "at least once" is a problem for anything that moves money.
Idempotency keys
The fix is to let the client label each logical operation with a unique key:
POST /charges
Idempotency-Key: 9f8b2c1a-1d3e-4a5b-8c7d-2e1f0a9b8c7d
The server promises: for a given key, the operation runs at most once, and every request carrying that key gets the same response — whether it's the first attempt or the fifth retry.
The client generates the key once (a UUID) and reuses it for every retry of that one operation. A genuinely new operation gets a new key.
The server-side flow
| Step | Action |
|---|---|
| 1 | Look up the key. A hit with a stored response? Return it verbatim. |
| 2 | On a miss, insert the key in an in-progress state (unique constraint). |
| 3 | Do the work — charge the card — inside a transaction. |
| 4 | Store the response body and status against the key. |
| 5 | Return the response. |
The unique constraint in step 2 is what makes this safe under concurrency. If two retries race, only one wins the insert; the loser sees the row already exists and returns (or waits for) the winner's result instead of charging again.
func (h *Handler) Charge(w http.ResponseWriter, r *http.Request) {
key := r.Header.Get("Idempotency-Key")
if key == "" {
http.Error(w, "Idempotency-Key required", http.StatusBadRequest)
return
}
if saved, ok := h.store.Get(key); ok {
writeJSON(w, saved.Status, saved.Body) // replay the stored result
return
}
if !h.store.Claim(key) { // atomic insert; false if another request won
http.Error(w, "request already in progress", http.StatusConflict)
return
}
resp := h.charge(r) // the real work, exactly once
h.store.Save(key, resp)
writeJSON(w, resp.Status, resp.Body)
}
The details that bite
- Scope the key. Store it per endpoint and per account, so one tenant's key can't collide with another's.
- Same key, different body. If a retry arrives with a different payload than
the original, that's a client bug. Fingerprint the request body and reject the
mismatch (
422) rather than serving the wrong stored result. - Expire keys. Retries happen within minutes, not months. A 24-hour TTL keeps the table bounded.
- Only cache completed work. Don't store a response until the transaction commits, or you'll replay a result for work that actually rolled back.
Idempotency keys don't make an operation idempotent by magic. They give you a place to remember that it already happened — and remembering is enough.
Stripe popularized the Idempotency-Key header, and it's since become the default
pattern for payments, provisioning, and any write where "just run it again" isn't
a safe answer.