Articles › Programming

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.

Intermediate 9 min read October 22, 2026

What you will learn
  • 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:

  1. The connection never happens. The server never saw it; retrying is safe
  2. It fails halfway. Whether a retry is safe depends on whether the database rolled back the partial work
  3. 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:

  1. Claims the key. If unseen, record it as “in progress”
  2. Does the work exactly once, then stores the response status and body against the key
  3. 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.

Send key + bodyclient makes one key per actionClaim key (UNIQUE)a second request hits the wallDo the work oncecharge, shipStore status + bodynext to the keyRepeat key = replayno second run
Sent N times, done once: the server remembers the key

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:

SituationStatus per the IETF draft
Endpoint requires a key, none sent400 Bad Request
Same key, different body422 Unprocessable Content
Same key, first request still running409 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):

RetrynCeilingActual wait (uniform)Mean
100.5 × 1 = 0.5 s0 – 0.5 s0.25 s
210.5 × 2 = 1 s0 – 1 s0.5 s
320.5 × 4 = 2 s0 – 2 s1 s
430.5 × 8 = 4 s0 – 4 s2 s
540.5 × 16 = 8 s (hits the cap)0 – 8 s4 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.

retry 10.5 sretry 21 sretry 32 sretry 44 sretry 58 sactual wait = random between 0 and the ceiling (full jitter)
The ceiling doubles; the real wait is random below it

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

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.