Tutorial

Idempotency Keys: How Payment APIs Avoid Charging You Twice

Why a retried payment request can charge a customer twice, and how an idempotency key makes retries safe, with a tested Python and FastAPI implementation you can copy.

·
17 min read
·
AI-assisted

AI tooling disclosure

Generative AI materially contributed to this article.

Human review confirmed by the author.

Idempotency Keys: How Payment APIs Avoid Charging You Twice

You tap Pay. The spinner spins. Nothing happens. After ten seconds you tap again.

Later your bank app shows two charges for the same order.

Nobody was careless. The app did what apps do, which is retry when it doesn't hear back. The trouble is that "didn't hear back" and "didn't work" look identical from the outside.

Idempotency keys fix that. They're why Stripe, Adyen and PayPal can tell you "just retry" without you getting charged twice.

And they aren't magic. They come down to one extra header and one careful database step.

This post covers why retries are dangerous, how the big providers handle them, and how to build the pattern yourself. The code is tested, including the nasty cases: two identical requests at the same instant, and a server that dies halfway through.

The problem: a timeout tells you nothing

Here's the classic failure:

sequenceDiagram
    participant C as Client
    participant S as Your API
    participant B as Bank
    C->>S: POST /charges ($25)
    S->>B: charge $25
    B-->>S: success
    S--xC: response lost (timeout)
    Note over C: Did it work? No way to know.
    C->>S: POST /charges ($25) again
    S->>B: charge $25
    B-->>S: success
    S-->>C: 201 Created
    Note over B: The customer was charged twice

The client sent the request. The bank charged the card. The response got lost on the way back.

From the client's seat, that looks exactly like a request that never arrived. Three different failures produce the same symptom:

What actually went wrong

Did money move?

What the client sees

The request never reached the server

No

A timeout

The server crashed before charging

No

A timeout or a 5xx

The charge worked, but the response was lost

Yes

A timeout

Same symptom, opposite truths. Retry after the third one and you've charged twice. Don't retry after the first and the customer never gets their order.

A good client wants to retry. It just needs a way to say "this is the same request as before".

"Idempotent" in plain words

An operation is idempotent if doing it once or ten times has the same effect. Setting a thermostat to 21 degrees is idempotent. Pressing "plus one degree" isn't.

HTTP already builds this in. Per the HTTP spec (RFC 9110), some methods are idempotent and some aren't:

Method

Idempotent?

Why

GET, HEAD, OPTIONS

Yes

They only read

PUT

Yes

"Set this to X" twice still leaves X

DELETE

Yes

Deleted stays deleted

POST, PATCH

No

"Create a charge" twice creates two charges

Creating a charge is a POST. So HTTP gives you no protection exactly where money is involved.

Same effect, not same response. Delete a file twice and the second call may return 404 instead of 204. That's still idempotent. What counts is the effect on the server, not the status code.

The idea: the client names the operation

An idempotency key is a unique value the client makes up for each logical operation, sent in a header:

POST /charges HTTP/1.1
Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324
Content-Type: application/json

{"amount": 2500, "card": "tok_visa"}

The server remembers the result against that key. If the same key shows up again, it returns the saved result instead of doing the work a second time.

This is the decision the server makes on every request:

flowchart TD
    A["POST with an Idempotency-Key"] --> B{"Key present and valid?"}
    B -->|no| E1["400 Bad Request"]
    B -->|yes| C{"Seen this key before?"}
    C -->|no| D["Claim the key, do the work, save the result"]
    D --> R1["Return the result"]
    C -->|yes| F{"Same request body?"}
    F -->|no| E2["422 Unprocessable Content"]
    F -->|yes| G{"Did the first request finish?"}
    G -->|no| E3["409 Conflict, try again shortly"]
    G -->|yes| R2["Replay the saved response"]

The word "logical" matters. "Charge this customer $25 for order 1042" gets one key. Every retry of that charge sends the same key. A different charge gets a different key.

How Stripe, Adyen and PayPal do it

Most big payment providers have some version of this. The details differ:

Provider

Where the key goes

Key length

How long it's kept

Stripe

Idempotency-Key header

Up to 255 characters

May be removed after at least 24 hours (API v1)

Adyen

idempotency-key header

Up to 64 characters

Valid for at least 7 days

PayPal

PayPal-Request-Id header

Not stated on its requests page

Stored for up to 45 days

Square takes a different route and wants an idempotency_key field in the request body instead of a header.

A few details are worth knowing, because they show the design choices:

  • Stripe saves the first result, good or bad. For API v1 it stores the status code and body once the endpoint starts running, and replays it for the same key. That includes 500 errors.

  • Stripe compares parameters. Reuse a key with different parameters and you get an error, which catches accidental misuse.

  • Some failures aren't saved. If the request fails validation, or collides with another one still running, Stripe doesn't save a result, and you can retry.

  • Adyen tells you when to retry. If a request hits a transient problem, the response carries a transient-error header set to true. Retry with the same key only in that case.

  • PayPal recommends the header on every call that creates or changes data.

Stripe API v2 works differently. Stripe's docs say that with v2, retries don't repeat successful operations, and Stripe retries failed or partly failed work to completion when it can. Check which API version you're on before assuming v1 behavior.

Is it a standard?

Almost. There's an IETF draft called The Idempotency-Key HTTP Header Field. The newest revision, draft -08, was published on October 22, 2026. It's still a draft, not an RFC.

It suggests three status codes that line up with the flowchart above:

  • 400 when the key is missing on an operation that requires one

  • 422 when the key is reused with a different payload

  • 409 when the original request is still being processed

One small difference from common practice: the draft defines the value as a quoted string, like "8e03978e-...". Stripe's docs show it unquoted. Pick one format, document it, and validate it.

What makes a good key

The key is the client's job, so it's worth getting right:

  • Random, not guessable. A UUID v4 is the usual choice, and the draft recommends a UUID or similar random value.

  • One per logical operation. Create it when the user starts the action, and reuse it on every retry. A fresh key inside the retry loop gives you the header and none of the protection.

  • Saved with the pending operation. If the app restarts mid-payment, the retry must find the same key. Store it next to the pending order.

  • Never reused for a different request. Same key plus different parameters is a bug, and a good server will tell you so.

If you use Stripe's official libraries, Stripe says you can turn on automatic retries and it will generate keys and repeat requests for you when that's safe.

Let's build it

We'll build the server side, because that's where the hard parts live. It's a tiny payments API with five files:

File

What it does

idempotency.py

The key store: claim, replay, expire

payments.py

A fake bank, plus the charge logic that uses the store

main.py

A FastAPI endpoint wrapping it

client.py

A client that retries the right way

test_payments.py

Tests, including races and crashes

You need Python 3.10 or newer. The store uses SQLite so there's nothing to install, but the ideas carry over to Postgres.

mkdir payments-demo && cd payments-demo
python3 -m venv venv
source venv/bin/activate
pip install fastapi "uvicorn[standard]" pytest

On Windows PowerShell, activate with .\venv\Scripts\Activate.ps1.

Step 1: The key store

Create idempotency.py:

import hashlib
import json
import sqlite3
import time
from contextlib import closing
from dataclasses import dataclass


@dataclass
class Claim:
    kind: str  # "new", "replay", "in_progress" or "mismatch"
    status_code: int | None = None
    body: dict | None = None


def fingerprint(payload: dict) -> str:
    """Hash the request body so we can tell whether a key is being reused for something else."""
    canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
    return hashlib.sha256(canonical.encode()).hexdigest()


class IdempotencyStore:
    def __init__(self, path, ttl=24 * 3600, lock_timeout=30, clock=time.time):
        self.path, self.ttl, self.lock_timeout, self.clock = path, ttl, lock_timeout, clock
        with closing(self._connect()) as db:
            db.execute(
                """CREATE TABLE IF NOT EXISTS idempotency_keys (
                       account      TEXT NOT NULL,
                       key          TEXT NOT NULL,
                       request_hash TEXT NOT NULL,
                       status_code  INTEGER,          -- NULL while the first request is still running
                       body         TEXT,
                       locked_at    REAL NOT NULL,
                       created_at   REAL NOT NULL,
                       PRIMARY KEY (account, key)
                   )"""
            )

    def _connect(self):
        return sqlite3.connect(self.path, timeout=10, isolation_level=None)

    def claim(self, account: str, key: str, request_hash: str) -> Claim:
        now = self.clock()
        db = self._connect()
        try:
            db.execute("BEGIN IMMEDIATE")  # one writer at a time, so check-and-insert is atomic
            db.execute(
                "DELETE FROM idempotency_keys WHERE account = ? AND key = ? AND created_at < ?",
                (account, key, now - self.ttl),
            )
            inserted = db.execute(
                "INSERT OR IGNORE INTO idempotency_keys"
                " (account, key, request_hash, locked_at, created_at) VALUES (?, ?, ?, ?, ?)",
                (account, key, request_hash, now, now),
            ).rowcount
            if inserted:
                outcome = Claim("new")
            else:
                saved_hash, status, body, locked_at = db.execute(
                    "SELECT request_hash, status_code, body, locked_at FROM idempotency_keys"
                    " WHERE account = ? AND key = ?",
                    (account, key),
                ).fetchone()
                if saved_hash != request_hash:
                    outcome = Claim("mismatch")
                elif status is not None:
                    outcome = Claim("replay", status, json.loads(body))
                elif now - locked_at > self.lock_timeout:
                    # The first attempt looks dead (crashed?). Let this request take over.
                    db.execute(
                        "UPDATE idempotency_keys SET locked_at = ? WHERE account = ? AND key = ?",
                        (now, account, key),
                    )
                    outcome = Claim("new")
                else:
                    outcome = Claim("in_progress")
            db.execute("COMMIT")
            return outcome
        except BaseException:
            if db.in_transaction:
                db.execute("ROLLBACK")
            raise
        finally:
            db.close()

    def complete(self, account: str, key: str, status_code: int, body: dict) -> None:
        with closing(self._connect()) as db:
            db.execute(
                "UPDATE idempotency_keys SET status_code = ?, body = ? WHERE account = ? AND key = ?",
                (status_code, json.dumps(body), account, key),
            )

    def release(self, account: str, key: str) -> None:
        """Forget a key whose request never finished, so the client can retry."""
        with closing(self._connect()) as db:
            db.execute(
                "DELETE FROM idempotency_keys WHERE account = ? AND key = ? AND status_code IS NULL",
                (account, key),
            )

This is the heart of it. Everything else is plumbing around claim.

Each call to claim follows the same path:

  1. Start a transaction that takes the write lock. BEGIN IMMEDIATE means only one request at a time can be in this step.

  2. Drop this key's record if it's past retention. Then an old key can be reused as new.

  3. Try to insert a row. INSERT OR IGNORE with a primary key on (account, key) means exactly one request can succeed. If it did, it's the first, and the answer is new.

  4. Otherwise, read the row and decide. Different body means mismatch. A finished one means replay. One still running means in_progress.

The whole design hangs on step 3. The database decides who's first, so your code doesn't have to guess.

The mistake to avoid: checking first and inserting later.

# DON'T: two requests can both pass this check before either inserts
row = db.execute("SELECT 1 FROM idempotency_keys WHERE key = ?", (key,)).fetchone()
if row is None:
    do_the_work()  # real handlers do other work here, which widens the gap
    db.execute("INSERT INTO idempotency_keys ...")

With a gap between the check and the insert, simultaneous requests can all get through. In a small demo the gap is only microseconds, so the race is rare. In a real service that gap holds a query or a network call, and it gets much wider.

Three smaller details:

  • The fingerprint. fingerprint() hashes the body with sorted keys, so field order doesn't matter. Same key plus a different hash means someone's reusing a key for a different request.

  • The lock timeout. If a worker dies after claiming a key, the row would look busy forever. After 30 seconds a retry is allowed to take over.

  • The account. The key is unique per (account, key), so one customer's key can never match another's.

In Postgres, the same step is INSERT ... ON CONFLICT (account, key) DO NOTHING, followed by a check of how many rows were inserted.

Step 2: The payment logic

Create payments.py:

import threading
import time

from idempotency import IdempotencyStore, fingerprint


class CardDeclined(Exception):
    pass


class Bank:
    """A pretend payment processor. Like real ones, it also dedupes on the idempotency key."""

    def __init__(self, delay: float = 0.0):
        self.delay = delay
        self.calls = 0                     # how many times we were asked to charge
        self.charges: list[dict] = []      # how many times money actually moved
        self.lose_next_response = False    # charge the card, then "lose" the reply
        self._by_key: dict[str, dict] = {}
        self._lock = threading.Lock()

    def charge(self, amount: int, card: str, idempotency_key: str) -> dict:
        time.sleep(self.delay)
        with self._lock:
            self.calls += 1
            if idempotency_key in self._by_key:
                return self._by_key[idempotency_key]  # same key, same charge, no second debit
            if card == "tok_declined":
                raise CardDeclined("card_declined")
            charge = {"id": f"ch_{len(self.charges) + 1}", "amount": amount}
            self.charges.append(charge)
            self._by_key[idempotency_key] = charge
            lose, self.lose_next_response = self.lose_next_response, False
        if lose:
            raise TimeoutError("the bank charged the card, but the reply never arrived")
        return charge


class PaymentService:
    def __init__(self, store: IdempotencyStore, bank: Bank):
        self.store, self.bank = store, bank

    def create_charge(self, account: str, key: str | None, payload: dict):
        """Returns (status_code, body, extra_headers)."""
        if not key or len(key) > 255:
            return 400, {"error": "Send an Idempotency-Key header of 1 to 255 characters"}, {}

        claim = self.store.claim(account, key, fingerprint(payload))

        if claim.kind == "replay":
            return claim.status_code, claim.body, {"Idempotent-Replayed": "true"}
        if claim.kind == "mismatch":
            return 422, {"error": "This Idempotency-Key was already used with different parameters"}, {}
        if claim.kind == "in_progress":
            return 409, {"error": "A request with this Idempotency-Key is still being processed"}, {"Retry-After": "1"}

        try:
            # Pass our key down too, so the bank can dedupe if we have to try again.
            charge = self.bank.charge(payload["amount"], payload["card"], idempotency_key=f"{account}:{key}")
            status, body = 201, {"id": charge["id"], "status": "succeeded", "amount": charge["amount"]}
        except CardDeclined:
            status, body = 402, {"error": "card_declined"}  # a definite answer: safe to replay
        except Exception:
            self.store.release(account, key)  # outcome unknown: let the client retry
            raise

        self.store.complete(account, key, status, body)
        return status, body, {}

Start with Bank. It's a stand-in for a real payment processor, with test hooks to make it slow, make it decline, or "lose" a reply after charging.

Notice that it dedupes on a key too. Real processors do this, and it matters a lot, as you'll see in a moment.

Now create_charge, in order:

  1. Reject bad keys with a 400, before touching anything.

  2. Claim the key. Replays, mismatches and in-progress requests return early, with 201 or 402 as saved, then 422 and 409.

  3. Do the work. Call the bank.

  4. Save the outcome, then return it.

What gets saved is a design choice:

Outcome

What we do

Why

Charge succeeded (201)

Save and replay

The obvious case

Card declined (402)

Save and replay

A definite answer. A retry with the same key should get the same decline, and a new attempt should use a new key

Unexpected error or timeout

Release the key and re-raise

We don't know what happened, so the client must be able to try again

That last row is only safe because of one line:

self.bank.charge(..., idempotency_key=f"{account}:{key}")

We pass our key down to the bank. If we release the key and the client retries, the bank sees the same key and returns the existing charge instead of making a second one.

Idempotency has to go all the way down. A layer that dedupes requests but calls a non-idempotent dependency is only protecting half the path.

Stripe v1 saves even 500s. We don't, on purpose. Replaying a 500 is simple, but it also locks in a failure that might have been temporary. We can afford to re-run because the bank dedupes. If your downstream call has no idempotency support, saving the failure may be the safer choice.

Step 3: The FastAPI endpoint

Create main.py:

from fastapi import FastAPI, Header, Response
from pydantic import BaseModel, Field

from idempotency import IdempotencyStore
from payments import Bank, PaymentService

app = FastAPI(title="payments-demo")

bank = Bank(delay=0.6)  # slow on purpose, so a retry can overlap with the first request
service = PaymentService(IdempotencyStore("idempotency.db"), bank)


class ChargeRequest(BaseModel):
    amount: int = Field(gt=0, description="Amount in cents")
    card: str


@app.post("/charges")
def create_charge(
    body: ChargeRequest,
    response: Response,
    idempotency_key: str | None = Header(default=None),
    x_account: str = Header(default="acct_demo"),  # demo only: use your authenticated account instead
):
    status, payload, headers = service.create_charge(x_account, idempotency_key, body.model_dump())
    response.status_code = status
    for name, value in headers.items():
        response.headers[name] = value
    return payload


@app.get("/charges")
def list_charges():
    """Every time money actually moved. Handy for checking that retries didn't double-charge."""
    return bank.charges

The endpoint is thin. It reads the header, calls the service, and copies the status and headers onto the response.

Two things to notice. FastAPI rejects bad bodies with its own 422 before our code runs, so invalid requests never claim a key. And the body we fingerprint is the validated one, so defaults and field order can't cause false mismatches.

The service sets an Idempotent-Replayed: true header on replays, so clients and logs can tell a replay from fresh work.

Don't take the account from a header in real life. X-Account keeps the demo small. In a real API, use the authenticated caller, like the account behind the API key. The IETF draft warns that low-entropy or unscoped keys let attackers fetch other clients' saved responses.

Step 4: A client that retries correctly

Create client.py:

import json
import os
import time
import urllib.error
import urllib.request
import uuid

URL = os.environ.get("URL", "http://localhost:8000/charges")
RETRYABLE = {409, 429, 500, 502, 503, 504}


def charge(amount, card, attempts=5, timeout=0.25):
    key = str(uuid.uuid4())  # one key per logical charge, reused by every retry below
    data = json.dumps({"amount": amount, "card": card}).encode()

    for attempt in range(1, attempts + 1):
        request = urllib.request.Request(
            URL,
            data=data,
            method="POST",
            headers={"Content-Type": "application/json", "Idempotency-Key": key},
        )
        try:
            with urllib.request.urlopen(request, timeout=timeout) as response:
                replayed = response.headers.get("Idempotent-Replayed") == "true"
                print(f"attempt {attempt}: {response.status}" + (" (replayed)" if replayed else ""))
                return json.load(response)
        except urllib.error.HTTPError as error:
            print(f"attempt {attempt}: {error.code}")
            if error.code not in RETRYABLE:
                return json.load(error)
        except (TimeoutError, urllib.error.URLError) as error:
            print(f"attempt {attempt}: no answer ({type(error).__name__})")  # did it work? Unknown.
        time.sleep(0.2 * 2 ** (attempt - 1))  # back off before retrying with the SAME key

    raise RuntimeError(f"gave up on idempotency key {key}")


if __name__ == "__main__":
    print(charge(2500, "tok_visa"))
    print("charges on file:", json.load(urllib.request.urlopen(URL)))

The rule that makes it work is on one line: the key is created once, outside the loop, and every retry reuses it.

The client retries when it gets no answer, and on 409, 429 and 5xx responses. It backs off between tries. It does not retry other 4xx errors, because those mean the request itself needs fixing.

Start the server and run the client:

uvicorn main:app --port 8000

In a second terminal:

python client.py

The fake bank takes 0.6 seconds and the client's timeout is 0.25, so the first attempt gives up early. You should see something like this:

attempt 1: no answer (TimeoutError)
attempt 2: 409
attempt 3: 201 (replayed)
{'id': 'ch_1', 'status': 'succeeded', 'amount': 2500}
charges on file: [{'id': 'ch_1', 'amount': 2500}]

Exact timings can shift the attempt numbers a little, but the ending shouldn't: one charge on file.

Here's what happened:

sequenceDiagram
    participant C as Client
    participant S as Your API
    participant B as Bank
    C->>S: POST (key abc)
    S->>B: charge (key acct:abc)
    Note over C: Gives up after 250 ms
    C->>S: POST (key abc), retry
    S-->>C: 409, still processing
    B-->>S: success
    S->>S: save the result under abc
    C->>S: POST (key abc), retry
    S-->>C: 201, replayed

The client never learned whether the first attempt worked. It didn't need to. It kept asking with the same key until it got a definite answer.

Step 5: Test the ugly cases

Create test_payments.py:

import os
import tempfile
import threading

from idempotency import IdempotencyStore
from payments import Bank, PaymentService

PAYMENT = {"amount": 2500, "card": "tok_visa"}


class FakeClock:
    def __init__(self):
        self.now = 1_000_000.0

    def __call__(self):
        return self.now


def make_service(bank_delay=0.0):
    clock = FakeClock()
    store = IdempotencyStore(os.path.join(tempfile.mkdtemp(), "keys.db"), clock=clock)
    bank = Bank(delay=bank_delay)
    return PaymentService(store, bank), bank, store, clock


def test_retry_with_the_same_key_charges_once():
    service, bank, _, _ = make_service()
    first = service.create_charge("acct_1", "key-1", PAYMENT)
    second = service.create_charge("acct_1", "key-1", PAYMENT)

    assert first[0] == 201 and second[0] == 201
    assert second[1] == first[1]                       # same body back
    assert second[2] == {"Idempotent-Replayed": "true"}
    assert len(bank.charges) == 1 and bank.calls == 1  # the bank was only asked once


def test_new_key_means_new_charge():
    service, bank, _, _ = make_service()
    service.create_charge("acct_1", "key-1", PAYMENT)
    service.create_charge("acct_1", "key-2", PAYMENT)
    assert len(bank.charges) == 2


def test_missing_key_is_rejected():
    service, bank, _, _ = make_service()
    status, _, _ = service.create_charge("acct_1", None, PAYMENT)
    assert status == 400 and bank.calls == 0


def test_oversized_key_is_rejected():
    service, bank, _, _ = make_service()
    status, _, _ = service.create_charge("acct_1", "k" * 256, PAYMENT)
    assert status == 400 and bank.calls == 0


def test_same_key_with_different_body_is_rejected():
    service, bank, _, _ = make_service()
    service.create_charge("acct_1", "key-1", PAYMENT)
    status, _, _ = service.create_charge("acct_1", "key-1", {"amount": 9900, "card": "tok_visa"})
    assert status == 422 and len(bank.charges) == 1


def test_a_decline_is_replayed_not_retried():
    service, bank, _, _ = make_service()
    declined = {"amount": 2500, "card": "tok_declined"}
    first = service.create_charge("acct_1", "key-1", declined)
    second = service.create_charge("acct_1", "key-1", declined)

    assert first[0] == 402 and second[0] == 402
    assert second[2] == {"Idempotent-Replayed": "true"}
    assert bank.calls == 1                             # the second attempt never reached the bank


def test_keys_are_scoped_to_the_account():
    service, bank, _, _ = make_service()
    service.create_charge("acct_1", "key-1", PAYMENT)
    status, _, headers = service.create_charge("acct_2", "key-1", PAYMENT)
    assert status == 201 and headers == {} and len(bank.charges) == 2


def test_eight_simultaneous_requests_charge_once():
    service, bank, _, _ = make_service(bank_delay=0.2)
    barrier = threading.Barrier(8)
    results = []

    def attempt():
        barrier.wait()                                  # start all eight at the same moment
        results.append(service.create_charge("acct_1", "key-1", PAYMENT))

    threads = [threading.Thread(target=attempt) for _ in range(8)]
    for t in threads:
        t.start()
    for t in threads:
        t.join()

    did_the_work = [r for r in results if r[0] == 201 and not r[2]]
    assert len(did_the_work) == 1                       # exactly one request ran the charge
    assert all(r[0] in (201, 409) for r in results)     # the rest waited (409) or got the saved answer
    assert len(bank.charges) == 1 and bank.calls == 1   # and only one of them reached the bank
    # Once it finishes, a retry just gets the saved answer.
    assert service.create_charge("acct_1", "key-1", PAYMENT)[2] == {"Idempotent-Replayed": "true"}


def test_lost_response_then_retry_charges_once():
    service, bank, _, _ = make_service()
    bank.lose_next_response = True
    try:
        service.create_charge("acct_1", "key-1", PAYMENT)
        raise AssertionError("expected a timeout")
    except TimeoutError:
        pass

    status, _, _ = service.create_charge("acct_1", "key-1", PAYMENT)  # the client retries
    assert status == 201
    assert len(bank.charges) == 1 and bank.calls == 2   # asked twice, charged once


def test_crash_after_charging_is_recovered_once_the_lock_expires():
    service, bank, store, clock = make_service()
    # Pretend a worker claimed the key, charged the card, then died before saving anything.
    store.claim("acct_1", "key-1", fingerprint_of(PAYMENT))
    bank.charge(2500, "tok_visa", idempotency_key="acct_1:key-1")

    assert service.create_charge("acct_1", "key-1", PAYMENT)[0] == 409   # still looks busy
    clock.now += store.lock_timeout + 1
    assert service.create_charge("acct_1", "key-1", PAYMENT)[0] == 201   # takes over
    assert len(bank.charges) == 1


def test_keys_expire_after_the_ttl():
    _, _, store, clock = make_service()
    store.claim("acct_1", "key-1", "hash")
    store.complete("acct_1", "key-1", 201, {"ok": True})
    assert store.claim("acct_1", "key-1", "hash").kind == "replay"
    clock.now += store.ttl + 1
    assert store.claim("acct_1", "key-1", "hash").kind == "new"


def fingerprint_of(payload):
    from idempotency import fingerprint
    return fingerprint(payload)

Run it:

pytest -q

These are plain functions with no fixtures, so they run under pytest as they are.

The ones to read closely:

  • test_eight_simultaneous_requests_charge_once starts eight threads at the same moment with the same key. Exactly one does the work, and only one request ever reaches the bank. Swap the claim for the check-then-insert version from Step 1, add a 10 ms pause between the check and the insert, and this test fails every time.

  • test_lost_response_then_retry_charges_once has the bank charge the card and then "lose" the reply. The service releases the key, the retry goes through, and the bank dedupes. The bank was asked twice and charged once.

  • test_crash_after_charging_is_recovered_once_the_lock_expires fakes a worker that charged the card and died. The retry gets a 409 at first, then takes over after the lock timeout.

The clock is a fake you control, so expiry and lock timeouts are tested without sleeping.

What can still go wrong

This design handles the common failures. Here's the full map, including where it stops helping:

Situation

What happens here

Client times out but the server did the work

The retry gets the saved response, or a 409 until it's saved

Two identical requests at once

One claims the key. The other gets 409

Same key, different body

422, and nothing runs

Server dies mid-request

The key looks busy. After the lock timeout a retry takes over, and the bank dedupes by key

Bank times out, outcome unknown

The key is released. The retry reuses the bank key, so there's no second debit

Your downstream call has no idempotency support

This layer can't save you

That last row is the hard one. If a dependency can't dedupe, a crash between "charged" and "saved" can leave you not knowing what happened.

The standard answer, described in Brandur Leach's well-known write-up on Stripe-style idempotency keys, is to split the work into atomic phases. Each phase is a database transaction.

After each one you record a "recovery point", so a retry resumes from the last known step instead of starting over. Side effects you can't roll back, like calling an outside API, happen between phases.

If you can't do that, plan for reconciliation: a job that compares your records with the provider's.

Before you ship it

A checklist for taking the pattern to production:

  • Scope keys to the authenticated account, as in the note under Step 3.

  • Validate the key. Enforce a length limit and a format, and prefer random UUIDs.

  • Match retention to your clients' retry window. Providers range from 24 hours to 45 days. Run a background job to delete old rows, because the demo only deletes a key when it's touched again.

  • Keep sensitive data out of saved responses. The store holds a copy of what you returned, so don't save anything you wouldn't want sitting in a table.

  • Share a transaction when you can. If the business write and the key record live in the same database, commit them together.

  • Watch the counters. Count replays, 409s and 422s. A spike usually means a client bug.

  • Retry with backoff and jitter. And cap the attempts. When a client gives up, log the key so support can look the operation up.

  • Test the races. A happy-path test will pass on a broken implementation.

Recap

  • A timeout doesn't tell the client whether the work happened. Retrying blindly can repeat it.

  • An idempotency key lets the client say "this is the same operation", and lets the server answer from memory.

  • One key per logical operation, reused on every retry.

  • The server must claim the key atomically. Check-then-insert has a race.

  • Save definite outcomes. For unknown ones, release the key.

  • Pass the key to whatever you call next, so the protection goes all the way down.


Checked against the Stripe, Adyen and PayPal documentation and the IETF draft (revision -08) on October 26, 2026. Provider limits change, so confirm key length and retention in their current docs before you rely on them.

Sources

Comments(0)

Join the discussion by logging into your account.

No comments yet. Be the first to comment!

Samod Alex

Passionate developer sharing knowledge about modern web technologies and best practices.

Subscribe to Samod Alex's Newsletter

Direct email dispatches when new stories are published. Zero algorithms.