Skip to content

Idempotency Keys: How Payment APIs Avoid Double Charges

Core Concept LearningOctober 22, 202616 min read

A customer clicks Pay. Your server charges the card, and then the connection drops before the response reaches the browser. The client cannot tell whether the charge happened. If it gives up, a paid order may never be recorded; if it retries, the customer may be charged twice. Stripe's documentation puts the problem plainly: restoring connectivity doesn't tell you whether the original request succeeded.

An idempotency key solves this. The client attaches a unique key to the request and reuses it on every retry. The server records the key with the outcome of the first attempt, and when the same key arrives again it returns that stored outcome instead of doing the work twice. Stripe has used this design for years, and an IETF draft proposes standardising it as the Idempotency-Key HTTP header.

This guide implements idempotency keys end to end on PostgreSQL: the client contract, the table, claiming a key safely under concurrency, calling an external payment provider, recovering from a crash mid-request, replaying responses, and expiry. The 57-line implementation was tested against PostgreSQL 17 with concurrent clients and injected crashes, and the results are below. For the consumer-side version of the same idea in message queues, see Exactly-Once Delivery: Myth vs Reality.

Why idempotency keys exist: when a response is lost the client cannot tell whether the charge happened, so it retries; without a key the server charges twice, with a key it replays the first response
Why idempotency keys exist: when a response is lost the client cannot tell whether the charge happened, so it retries; without a key the server charges twice, with a key it replays the first response

The problem: an unknown outcome

Some HTTP methods are idempotent by definition. RFC 9110 defines PUT, DELETE and the safe methods (GET, HEAD, OPTIONS, TRACE) as idempotent: sending the same request twice has the same intended effect as sending it once. POST is not, and "create an order" or "charge this card" is a POST.

When a POST fails, the failure can happen at three points:

  • Before the server received it: nothing happened, and retrying is safe.
  • While the server was working: the work may be half done.
  • After the work, before the response arrived: everything happened, and a retry repeats it.

The client sees the same thing in all three cases: a timeout or a dropped connection. Stripe's advice for a 500 response is the same: treat the outcome as indeterminate, because the operation might have produced side effects even though you received an error.

Retrying is the right reaction to most transient failures, and clients, SDKs, proxies and users pressing the button again all retry. The job of an idempotency key is to make those retries safe: the server, not the client, decides whether a request is new.

Quick reference

  • Idempotency is about effects, not responses. A repeated DELETE may return 404 the second time; the resource state is still the same.
  • A double-click on a submit button is the most common duplicate, and it is a concurrent duplicate, not a sequential retry.
  • Retry policies for unreliable APIs are covered in LLM API Timeouts, Retries and Jitter; idempotency is what makes them safe for writes.

Remember this

A failed POST has an unknown outcome, so retries are only safe if the server can recognise a repeated request.

The contract: what Stripe and the IETF draft specify

Stripe's API is the reference implementation most people copy. For API v1 its documentation says:

  • The client generates the key and sends it in the Idempotency-Key header. Stripe suggests V4 UUIDs or another random string with enough entropy, up to 255 characters, and no sensitive data such as email addresses.
  • Stripe saves the status code and body of the first request after endpoint execution begins, and retries with the same key return that saved response, including 500 errors.
  • If parameters fail validation, or the request conflicts with another request using the same key that is still executing, nothing is saved, and the request can be retried.
  • The idempotency layer compares incoming parameters with the original request and returns an error if they differ.
  • Keys can be removed once they are at least 24 hours old. A key reused after that creates a new request.
  • A replayed response carries the header Idempotent-Replayed: true.

The IETF HTTPAPI working group's draft, The Idempotency-Key HTTP Header Field (draft-07, October 2025), describes the same behaviour as a general standard. The key is a Structured Header string, for example Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324". The server may combine it with a fingerprint of the payload. A missing key on an operation that requires one should get a 400, a key reused with a different payload a 422, and a retry while the original is still in progress a 409. Servers must publish their expiry policy.

Treat the draft as guidance, not a standard: it is an Internet-Draft, not an RFC, and the IETF datatracker lists the -07 version as expired.

Quick reference

  • Stripe says not to send idempotency keys on GET and DELETE in API v1, because those requests are already idempotent.
  • Stripe's API v2 behaves differently: it retries failed or partially failed work to completion where possible instead of replaying a saved error.
  • The draft leaves the fingerprint algorithm to the server: a checksum of the whole payload, of selected fields, or a field-by-field comparison.

Remember this

The client sends a unique key; the server stores the first outcome, replays it on retries, and rejects reuse with a different payload.

The client side: one key per operation

The most common mistake is generating the key in the wrong place. A key identifies one operation (this checkout), not one HTTP attempt. If a retry loop generates a fresh UUID on each attempt, every retry looks new to the server and the protection disappears.

The rules on the client:

  • Generate the key when the user starts the operation, for example when the checkout form is rendered or submitted, and keep it for every attempt.
  • Persist it if retries can cross restarts. Stripe's documentation says that if your application retries across separate SDK calls or process restarts, it should generate and persist a key for the operation and supply it to every attempt. A key held only in memory is lost when a background worker crashes.
  • Send the same parameters with the same key. A changed amount with an old key is a different operation, and the server should reject it.
  • Stop retrying before the key expires. Stripe says to limit retries of an unresolved operation to the first 24 hours. After that, retrieve the object or check webhooks to find out what happened, rather than retrying with either the old key or a new one.

Stripe also describes a second way to make keys: derive them from an object the user owns, such as a shopping cart ID. That makes double submissions of the same cart collapse into one without the client storing anything extra. The trade-off is that the cart can then only ever produce one order, so a deliberate second purchase needs a new cart.

Quick reference

  • In a browser, put the key in a hidden form field rendered with the page, so a double-click submits the same key twice.
  • Scope keys to the authenticated user on the server; two users who happen to send the same key are different operations.
  • Log the key with every attempt, so support can trace one operation across several requests.

Remember this

Create the key once per user operation, persist it if retries can outlive the process, and never change parameters under the same key.

The server: a key table and a claim

The server needs one row per (user, key) with the request's fingerprint, its progress and, once finished, the stored response:

1CREATE TABLE idempotency_keys (2  user_id       bigint      NOT NULL,3  key           text        NOT NULL,4  fingerprint   text        NOT NULL,5  status        text        NOT NULL DEFAULT 'started',  -- started | finished6  locked_until  timestamptz NOT NULL,7  lock_token    uuid        NOT NULL,8  response_code int,9  response_body jsonb,10  created_at    timestamptz NOT NULL DEFAULT now(),11  PRIMARY KEY (user_id, key)12);13CREATE INDEX ON idempotency_keys (created_at);14 15CREATE TABLE orders (16  id      bigserial PRIMARY KEY,17  user_id bigint NOT NULL,18  amount  int    NOT NULL,19  charge  text   NOT NULL20);

Every request starts by claiming its key, in its own short transaction:

1. Try to insert a new row with status = 'started', a lock that expires in 30 seconds, and a random lock token. ON CONFLICT DO NOTHING ... RETURNING 1 tells you whether the insert happened. If it did, this request owns the key. 2. If the row already existed, lock it with SELECT ... FOR UPDATE and decide: - Different fingerprint: reject with 422; the key was reused for a different request. - Finished: return the stored status code and body without doing anything. - Started and still locked: another request is working on it; return 409. - Started but the lock has expired: the previous attempt died or stalled; take the key over with a new lock token and continue.

The primary key on (user_id, key) is what makes this safe. Two transactions inserting the same key cannot both succeed: PostgreSQL makes the second wait until the first commits, and then its insert does nothing and it falls through to the checks.

What the server does with an incoming key: a new key is claimed, a different payload is rejected with 422, a finished key replays its response, a key still being processed returns 409, and an expired lock is taken over
What the server does with an incoming key: a new key is claimed, a different payload is rejected with 422, a finished key replays its response, a key still being processed returns 409, and an expired lock is taken over

Quick reference

  • The fingerprint here is a SHA-256 of the parameters serialised as JSON with sorted keys, so key order in the request body does not matter.
  • Store the fingerprint, not the raw payload, if the payload contains card or personal data.
  • The created_at index serves the expiry job, which deletes old keys in batches.

Remember this

Claim the key with an insert guarded by a unique constraint, then replay, reject, conflict or take over based on the stored row.

The implementation

Here is the whole implementation in Python with psycopg 3. create_order is what an HTTP handler would call:

1import hashlib, json, uuid2from psycopg.types.json import Jsonb3 4LOCK_SECONDS = 305 6class Conflict(Exception): pass    # 409: the same key is still being processed7class KeyReused(Exception): pass   # 422: the same key was sent with a different request8 9def fingerprint(params):10    return hashlib.sha256(json.dumps(params, sort_keys=True).encode()).hexdigest()11 12def claim(conn, user_id, key, fp):13    """Phase 1: insert or lock the key. Returns (token, None) to proceed or (None, response) to replay."""14    token = uuid.uuid4()15    with conn.transaction():16        inserted = conn.execute(17            """INSERT INTO idempotency_keys (user_id, key, fingerprint, locked_until, lock_token)18               VALUES (%s, %s, %s, now() + make_interval(secs => %s), %s)19               ON CONFLICT DO NOTHING RETURNING 1""",20            (user_id, key, fp, LOCK_SECONDS, token)).fetchone()21        if inserted:22            return token, None23        stored_fp, status, locked, code, body = conn.execute(24            """SELECT fingerprint, status, locked_until > now(), response_code, response_body25               FROM idempotency_keys WHERE user_id = %s AND key = %s FOR UPDATE""",26            (user_id, key)).fetchone()27        if stored_fp != fp:28            raise KeyReused(key)29        if status == "finished":30            return None, (code, body)31        if locked:32            raise Conflict(key)33        # The previous attempt died or stalled past its lock: take the key over.34        conn.execute(35            """UPDATE idempotency_keys SET locked_until = now() + make_interval(secs => %s), lock_token = %s36               WHERE user_id = %s AND key = %s""", (LOCK_SECONDS, token, user_id, key))37        return token, None38 39def create_order(conn, provider, user_id, key, params):40    token, replay = claim(conn, user_id, key, fingerprint(params))41    if replay:42        return replay[0], replay[1], True43    # Phase 2: the external call, keyed so the provider dedupes our own retries.44    charge_id = provider.charge(user_id, params["amount"], idempotency_key=f"{user_id}:{key}")45    # Phase 3: write the order and finish the key atomically, only if we still hold the key.46    with conn.transaction():47        order_id = conn.execute(48            "INSERT INTO orders (user_id, amount, charge) VALUES (%s, %s, %s) RETURNING id",49            (user_id, params["amount"], charge_id)).fetchone()[0]50        body = {"order_id": order_id, "charge": charge_id}51        finished = conn.execute(52            """UPDATE idempotency_keys SET status = 'finished', response_code = 201, response_body = %s53               WHERE user_id = %s AND key = %s AND status = 'started' AND lock_token = %s54               RETURNING 1""", (Jsonb(body), user_id, key, token)).fetchone()55        if not finished:56            raise Conflict(key)  # another attempt took over; rolls back this order57    return 201, body, False

The request runs in three phases. Claim is the transaction above. The external call charges the card through the payment provider and passes a key derived from ours (user_id:key), so if this request is retried after a crash, the provider returns the same charge instead of making a new one. Finish writes the order and marks the key finished with the stored response, in a single transaction, but only if the key still carries this request's lock token.

The handler maps the exceptions to HTTP: KeyReused to 422 and Conflict to 409. Add an Idempotent-Replayed: true header when the third return value is true. Anything else, such as a provider outage, leaves the key in started, and a retry after the lock expires picks the operation up again.

Quick reference

  • Each phase is short. Holding a database transaction open across a network call to a payment provider would tie up a connection and a row lock for the length of that call.
  • make_interval(secs => ...) keeps the lock length a parameter, so tests can shorten it.
  • The same structure works with any database that has unique constraints and row locks; the SQL is PostgreSQL syntax.

Remember this

Claim in one transaction, call out with a derived key, and finish in a second transaction that checks you still own the key.

Concurrent duplicates and stalled requests

Two kinds of duplicate are harder than a sequential retry.

Simultaneous duplicates. A double-click or an aggressive client sends the same key twice at once. In the test, ten threads sent the same key at the same moment, with the provider taking 0.3 seconds per charge. One request got 201 and the other nine got 409, with exactly one charge and one order. The unique constraint serialised the inserts, and the losers found a started, locked row. Returning 409 rather than waiting matches the IETF draft. It also matches Stripe, which does not save a result for a request that conflicts with one still executing, so the client simply retries a moment later and gets the stored response.

Stalled requests. The lock exists so that a request that crashes does not hold its key forever. But a lock that expires is also a risk: a request that is merely slow can still be running when its lock expires and a retry takes over. Both then reach the finish phase. The lock token prevents a double write. The finish transaction updates the key only WHERE lock_token = <mine>, and if that matches no row, it raises and rolls back the order it just inserted. In the test, the original request was slowed to 2 seconds with a 1-second lock, and a retry arrived at 1.3 seconds. The retry finished with 201, the original got 409 and its order was rolled back, leaving one order. The provider made one charge, because both used the same derived key.

The lock length is a trade-off. Too short, and slow requests are taken over while still working, wasting work. Too long, and a crashed request blocks retries for that long. Set it above the slowest normal completion time of the operation.

Three phases of an idempotent request: claim the key in a transaction, call the payment provider with a derived key, then write the order and finish the key in a second transaction that checks the lock token
Three phases of an idempotent request: claim the key in a transaction, call the payment provider with a derived key, then write the order and finish the key in a second transaction that checks the lock token

Quick reference

  • Brandur Leach's Stripe-style design (2017) calls these phases "atomic phases" and records a "recovery point" on the key after each one, so a retry resumes from the last completed step.
  • The derived downstream key is what makes takeover safe. Without it, a takeover after a crash would charge the card a second time.
  • If the external API has no idempotency support, record the attempt before calling it and reconcile unknown outcomes from the provider's records instead of retrying blindly.

Remember this

A unique constraint turns simultaneous duplicates into 409s, and a lock token stops a stalled request from finishing over its replacement.

What the tests showed

Each scenario ran against a fresh PostgreSQL 17 database. A stub payment provider counted real charges and, like Stripe, returned the existing charge when it saw a key twice.

The crash scenario is the one that shows the design working end to end. The provider charged the card and then the process "died" before the finish phase. An immediate retry got 409, because the dead request's lock was still active. A retry after the 1-second lock expired took the key over, called the provider with the same derived key, received the original charge, wrote the order and stored the response. A final retry got that response replayed. One charge, one order, and a correct answer for the client.

The last row is the baseline: without keys, ten concurrent submissions made ten charges.

These tests check the logic, not production behaviour. They do not cover a database failover mid-transaction, clock skew between application servers (the lock uses the database's now(), which avoids most of it), or a provider that does not dedupe. For the last case, the guarantee is only as strong as the weakest system in the chain.

Scenarios run against PostgreSQL 17 with a stub payment provider that dedupes on its idempotency key
ScenarioProvider chargesOrdersResponses
Same request sent twice11201, then 201 replayed
10 concurrent requests, same key11one 201, nine 409
Same key, different amount11201, then 422
Crash after the charge, retry before and after the lock expires11error, 409, 201, 201 replayed
Slow original overtaken by a retry after its lock expired11retry 201, original 409
No idempotency key, 10 concurrent requests1010ten 201

Quick reference

  • Run the scenarios yourself with a disposable database: docker run -e POSTGRES_PASSWORD=pw -p 5432:5432 postgres:17.
  • Assert on counts (charges and orders), not just on status codes; a bug can return 201 twice and look fine.
  • Add a test where the provider itself fails with a 5xx, and check that the key stays retryable.

Remember this

With keys, every duplicate pattern tested produced one charge and one order; without them, ten clicks made ten charges.

Which outcomes to store, and expiry

Deciding what to store is a policy choice, and Stripe's two API versions make different choices.

  • Success: always store and replay it. That is the point of the key.
  • Validation errors before any work: do not store them. Stripe does not save a result when parameters fail validation, so the client can fix the request and retry with the same key.
  • Definite business failures, such as a declined card: storing them makes retries return the same decline, which is usually right, since a retry will not change the bank's answer.
  • Server errors and crashes: Stripe's API v1 stores 500 responses and replays them, and tells clients to treat them as indeterminate and reconcile. The implementation here does the opposite. It leaves the key started so a retry after the lock expires resumes the work, which is safe only because the provider call is itself idempotent. Stripe's API v2 is closer to this: it retries failed work to completion where possible.

Expiry. Keys cannot be kept forever, and the IETF draft asks servers to publish how long they keep them. Stripe keeps them for at least 24 hours. A periodic job such as DELETE FROM idempotency_keys WHERE created_at < now() - interval '24 hours', run in batches, enforces this. After expiry, the same key creates a new operation. That is why the client must stop retrying before the window ends and reconcile instead.

Quick reference

  • Never delete a key that is still started and locked, or a running request loses its protection; the age-based delete is safe as long as the window is far longer than any request.
  • Replay the stored body exactly, including IDs, so clients that retried get the same order ID as the first response.
  • Document the window and the error codes in your API reference; the draft says this is part of the contract.

Remember this

Store successes and definite failures, decide deliberately about server errors, and expire keys on a published schedule.

Where idempotency keys stop helping

Idempotency keys protect one API boundary. Three situations need more.

Downstream side effects. The key protects your endpoint, but your endpoint may send an email, publish an event or call a second service. Each of those needs its own protection: a derived key for APIs that accept one, or a record of what was already sent. Publishing events reliably from the finish transaction is the job of the Transactional Outbox Pattern.

Asynchronous consumers. Message brokers redeliver messages, and a consumer must deduplicate by message ID in the same transaction as its side effect. That is the same idea applied to queues rather than HTTP; see Exactly-Once Delivery.

Keys the client never persisted. If the client loses the key, for example a mobile app killed before it saved it, the next attempt is a new operation. Deriving the key from a stable object, such as a cart ID, or deduplicating on a business identifier covers this case.

Idempotency also does not make an operation correct. A replayed response is the first response, whatever it was. If the first attempt charged the wrong amount, every retry faithfully returns that mistake.

Quick reference

  • Agent tools that write data need the same protection: an agent loop that retries a tool call is a client retrying a POST.
  • For multi-service workflows, idempotent steps are what make saga compensations and retries safe.
  • Monitor the 409 and replay rates; a spike usually means a client bug or a retry storm.

Remember this

Keys make one endpoint safe to retry; every downstream side effect needs its own deduplication.

Practice: build it and break it

Build the implementation against a throwaway PostgreSQL and reproduce the test table:

1. Start PostgreSQL 17 in Docker, create the two tables, and write a stub provider that counts charges and dedupes on its key. 2. Send the same request twice and confirm one charge, with the second response replayed. 3. Start ten threads with the same key and a 0.3-second provider delay. Count charges, orders and 409s. 4. Send the same key with a different amount and check for KeyReused. 5. Make the provider raise after charging, retry immediately and again after the lock expires, and check that you end with one charge and one order. 6. Remove the lock_token condition from the finish phase, repeat the slow-original scenario (2-second provider, 1-second lock), and count the orders.

Pass criterion: steps 2–5 each end with exactly one charge and one order, and step 6 produces two orders, which explains why the token check exists.

Quick reference

  • Use a separate connection per thread; sharing one psycopg connection across threads serialises them and hides races.
  • Shorten LOCK_SECONDS in tests; keep it realistic in production.
  • Write each scenario as an automated test so the guarantees survive refactoring.

Remember this

Removing the lock token once and watching a duplicate order appear is the clearest proof of why it is there.

Key takeaway

An idempotency key turns "I don't know if that worked" into a safe retry. The client generates one key per operation, persists it if retries can outlive the process, and sends it with identical parameters on every attempt. The server claims the key in a short transaction guarded by a unique constraint. It replays finished results, rejects reused keys with 422, answers in-flight duplicates with 409, and takes over keys whose lock has expired. External calls get a derived key so that retries after a crash do not repeat the charge. A lock token in the finish transaction stops a stalled request from writing over its replacement.

Stripe's API is the working reference and the IETF draft is the emerging standard, though still only a draft. Both agree on the contract, and both leave the storage policy and expiry window to the server, which must document them. Beyond the endpoint, every downstream side effect needs its own deduplication; see Transactional Outbox Pattern and Exactly-Once Delivery.

Practice (60 min): build it against PostgreSQL and work through the six steps above.

Share:

References

  1. Stripe API reference: Idempotent requests — checked Oct 22, 2026
  2. Stripe docs: Advanced error handling (idempotency, network and server errors) — checked Oct 22, 2026
  3. IETF draft-ietf-httpapi-idempotency-key-header-07, The Idempotency-Key HTTP Header Field — checked Oct 22, 2026
  4. RFC 9110, HTTP Semantics, section 9.2.2 Idempotent Methods — checked Oct 22, 2026
  5. Brandur Leach, Implementing Stripe-like Idempotency Keys in Postgres (2017) — checked Oct 22, 2026
  6. PostgreSQL docs: INSERT ... ON CONFLICT — checked Oct 22, 2026
SH

Shoaib Hossain

Lead Author & Systems Architect

Software engineer and distributed systems architect specializing in backend scalability, cloud-native infrastructure, databases, and AI engineering workflows. Author and maintainer of Core Concept Learning.

Human-Engineered & Fact-CheckedOriginal Visual DiagramsEditorial Standards →Send Feedback

Related Articles

Model APIs fail in ordinary ways: rate limits, overloaded capacity, server errors, dropped connections and calls that ru…

Read

PostgreSQL never overwrites a row in place. An UPDATE writes a new version of the row and marks the old one as replaced;…

Read

An order-ingestion API writes to an in-memory queue, a worker pool drains it, and a payment provider on the other end ge…

Read

Keep learning

Follow a structured path or browse all courses to go deeper.