Idempotency keys: a double-clicked Pay button must not charge twice
A user clicks Pay twice, or a client retries after a dropped connection. Idempotency keys, stored results, unique constraints and jittered backoff keep it to one charge.
- The client creates one key per action (a UUID v4, say) and sends the same key on every retry
- The server stores the key with a hash of the request and the result, and replays that result for a repeated key
- A unique constraint in the database is what actually stops the double charge, not a check in code
- Retry with exponential backoff and full jitter, and only on retryable errors: network failures, 409, 429, 5xx
A customer clicks “Pay”, the page hangs, so they click again. Or a mobile app sends a request, the connection drops before the response arrives, and it retries on its own. If the server executes every request it receives, the customer is charged twice. An idempotency key is the standard way to make “sent N times” mean the same as “sent once”. This article follows Stripe’s model and the IETF draft, with code you can run.
Tested on Python 3.12 and SQLite 3.49 (the standard-library sqlite3 module). Stripe behaviour checked against the Stripe API v1 docs as of October 2026.
Why a plain retry is dangerous
Stripe’s 2017 post splits request failures into three cases:
- The connection never happens. The server never saw it; retrying is safe
- It fails halfway. Whether a retry is safe depends on whether the database rolled back the partial work
- It succeeds, but the response is lost. This is the dangerous one. The client cannot know it worked, and a retry without a key charges again
From the client’s side, cases 1 and 3 look identical: both are a timeout. So the server has to be the one that remembers whether it already did this.
How an idempotency key works
The client generates a unique key per action (not per request) and sends it in the Idempotency-Key header, reusing the same key on every retry:
POST /v1/payments HTTP/1.1
Host: api.example.com
Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324"
Content-Type: application/json
{"order_id": "A-1001", "amount_satang": 129900}
The server does three things:
- Claims the key. If unseen, record it as “in progress”
- Does the work exactly once, then stores the response status and body against the key
- On a repeated key, replays the stored response instead of running again
Stripe API v1 works exactly like this. It saves the status code and body of the first request once the endpoint starts executing, including 500 responses. Keys can be up to 255 characters, can be pruned once they are at least 24 hours old, and a repeated key with different parameters returns an error instead of running. Requests that fail validation, or that collide with a still-running request using the same key, are not saved and can be retried.
What the IETF draft says
The IETF draft draft-ietf-httpapi-idempotency-key-header reached -07 in October 2025 and has expired without becoming an RFC. Its status codes are still a sensible convention:
| Situation | Status per the IETF draft |
|---|---|
| Endpoint requires a key, none sent | 400 Bad Request |
| Same key, different body | 422 Unprocessable Content |
| Same key, first request still running | 409 Conflict |
The draft makes the header value a Structured Field String (hence the quotes), recommends a UUID or a similar random identifier, and asks servers to publish their key expiry policy in their docs. Stripe sends the value unquoted, so if you build your own API, accept both forms.
A key belongs to an action, not a request. Create it once when the user starts paying, and send that same key on every retry.
Example: a key table plus unique constraints
This runs on the Python standard library alone. It uses SQLite so you can try it without a server (ON CONFLICT ... DO NOTHING with RETURNING works in Postgres too; SQLite needs 3.35 or later). Amounts are integer satang (1/100 baht), for the reasons in why money is never a float.
import hashlib, json, sqlite3, time
con = sqlite3.connect("app.db", isolation_level=None)
con.executescript("""
CREATE TABLE IF NOT EXISTS idempotency_keys (
key TEXT PRIMARY KEY,
request_hash TEXT NOT NULL,
status TEXT NOT NULL, -- 'started' | 'done'
response_code INTEGER,
response_body TEXT,
created_at INTEGER NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS charges (
id INTEGER PRIMARY KEY,
idempotency_key TEXT NOT NULL UNIQUE, -- second guard
order_id TEXT NOT NULL,
amount_satang INTEGER NOT NULL
) STRICT;
""")
def pay(key: str, body: dict) -> tuple[int, dict]:
h = hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest()
# 1) claim the key
con.execute("BEGIN IMMEDIATE")
claimed = con.execute(
"INSERT INTO idempotency_keys(key, request_hash, status, created_at) "
"VALUES (?,?, 'started', ?) ON CONFLICT(key) DO NOTHING RETURNING key",
(key, h, int(time.time()))).fetchone()
if not claimed:
row = con.execute("SELECT request_hash, status, response_code, response_body "
"FROM idempotency_keys WHERE key=?", (key,)).fetchone()
con.execute("COMMIT")
if row[0] != h:
return 422, {"error": "key reused with a different body"}
if row[1] == "started":
return 409, {"error": "request still in progress"}
return row[2], json.loads(row[3]) # replay the saved result
con.execute("COMMIT")
# 2) do the work once, 3) save the result
con.execute("BEGIN IMMEDIATE")
cur = con.execute("INSERT INTO charges(idempotency_key, order_id, amount_satang) "
"VALUES (?,?,?)", (key, body["order_id"], body["amount_satang"]))
resp = {"charge_id": cur.lastrowid, "status": "paid"}
con.execute("UPDATE idempotency_keys SET status='done', response_code=201, "
"response_body=? WHERE key=?", (json.dumps(resp), key))
con.execute("COMMIT")
return 201, resp
Calling pay() three times with the same key, the third time with a different amount, prints:
(201, {'charge_id': 1, 'status': 'paid'})
(201, {'charge_id': 1, 'status': 'paid'}) <- replayed, no new charge
(422, {'error': 'key reused with a different body'})
1 charge(s)
What makes it correct is the PRIMARY KEY on the key table and the UNIQUE on charges. Two simultaneous requests that each “SELECT first to see if the key exists” will both see nothing and both proceed. An INSERT that hits a unique constraint lets exactly one through, however many processes you run. (SQLite in production explains why the writes use BEGIN IMMEDIATE.)
If step 2 calls an external payment gateway, never call it while holding the transaction. Commit the started row first as the code does, then pass your key on to the gateway (Stripe accepts Idempotency-Key too). If the process dies midway, the stuck started row answers 409 forever, so you need a sweeper that asks the gateway whether the charge actually happened and closes the row accordingly.
Client-side retries: exponential backoff plus jitter
With a key in place the client can retry safely, but it must retry politely, or every client hammers the server at the same instant as it recovers (a thundering herd). The AWS Architecture Blog’s comparison found backoff without jitter the clear loser, with Full Jitter cutting server work about as much as Decorrelated Jitter while having the shortest formula.
import random, time, uuid
import urllib.request, urllib.error
RETRYABLE = {409, 429, 500, 502, 503, 504}
def backoff(attempt: int, base: float = 0.5, cap: float = 8.0) -> float:
return random.uniform(0, min(cap, base * 2 ** attempt)) # full jitter
def post_payment(url: str, data: bytes, max_retries: int = 5):
key = str(uuid.uuid4()) # created once, reused every attempt
for attempt in range(max_retries + 1):
req = urllib.request.Request(url, data=data, method="POST", headers={
"Content-Type": "application/json", "Idempotency-Key": key})
try:
with urllib.request.urlopen(req, timeout=10) as r:
return r.status, r.read()
except urllib.error.HTTPError as e:
if e.code not in RETRYABLE or attempt == max_retries:
raise
except urllib.error.URLError: # network failure / connect timeout
if attempt == max_retries:
raise
time.sleep(backoff(attempt))
Against a local test server that answers 503 twice and then 201, this made three attempts, all with the same key.
Worked example: how long does it wait?
With base = 0.5 s, cap = 8 s and up to 5 retries, each wait’s ceiling is min(8, 0.5 × 2^n):
| Retry | n | Ceiling | Actual wait (uniform) | Mean |
|---|---|---|---|---|
| 1 | 0 | 0.5 × 1 = 0.5 s | 0 – 0.5 s | 0.25 s |
| 2 | 1 | 0.5 × 2 = 1 s | 0 – 1 s | 0.5 s |
| 3 | 2 | 0.5 × 4 = 2 s | 0 – 2 s | 1 s |
| 4 | 3 | 0.5 × 8 = 4 s | 0 – 4 s | 2 s |
| 5 | 4 | 0.5 × 16 = 8 s (hits the cap) | 0 – 8 s | 4 s |
- Worst-case total wait: 0.5 + 1 + 2 + 4 + 8 = 15.5 s; on average half that, 7.75 s (not counting the requests themselves)
- If 1,000 clients fail at once, without jitter all 1,000 retry at exactly 0.5 s. With full jitter the first round spreads over 0–500 ms: 1,000 ÷ 500 = 2 requests per ms instead of one spike of 1,000
All of this must fit inside the window in which the server remembers the key. Stripe says to retry with the same key within the first 24 hours; if the outcome is still unknown after that, stop and reconcile.
Common mistakes
- A new key on every retry. That is the same as no key. Create it when the action starts and keep it; if retries cross processes or restarts, persist the key
- SELECT, then INSERT. Loses the race when two requests arrive together. Rely on the unique constraint
- Retrying every 4xx. A 400 or 422 is wrong however many times you send it. Retry network failures, 409, 429 and 5xx only
- Switching to a new key after a 500. Stripe treats a 500 as an unknown outcome: the charge may have happened. Do not retry with a new key until you have checked (fetch the object, or wait for the webhook, which you should receive safely). And because Stripe v1 stores 500 responses, retrying with the same key returns the same 500
- Personal data as the key, such as an email or phone number. Stripe warns against this explicitly
- Pruning keys sooner than clients retry. A pruned key is treated as a new request
The unique constraint is what really prevents the double charge. A “check first” in code is only a shortcut to answer faster.
Sources
- Idempotent requests — Stripe API Reference
- Advanced error handling (idempotency, retries, 500) — Stripe Docs
- The Idempotency-Key HTTP Header Field, draft -07 — IETF
- INSERT … ON CONFLICT — PostgreSQL 18 Docs
- Designing robust and predictable APIs with idempotency — Stripe Blog
- Exponential Backoff And Jitter — AWS Architecture Blog
FAQ
Can I use the order id as the idempotency key?
Yes, if an order can only be paid once. Stripe suggests either a UUID v4 or an id from a user-attached object such as a shopping cart. If one order is paid in several instalments, the key must differ per instalment, or the second instalment gets the first one's result back.
How long should keys be kept?
Longer than any client will keep retrying. Stripe API v1 keeps keys for at least 24 hours and may prune them after that. If you prune sooner than clients retry, a pruned key runs the operation again.
Do GET requests need an idempotency key?
No. GET, PUT and DELETE are idempotent by HTTP's definition. Keys are for POST or PATCH requests with side effects, such as charging money, sending email or creating an order.
Commands and settings were tested on sample systems. Try them on a test machine before production.