Development

Idempotency Keys for Payment APIs: Design and Verification

Antonio Evans9 min read

A split scene: on the left, a developer's terminal shows a curl command sending a POST to /v1/charges with an Idempotency-Key header, then a retry of the same command.
Key points

Key points

  1. 1Payment clients should generate the idempotency key once per business operation and resend the same value on every retry, so the server can recognize repeats.
  2. 2The server must store the key with the original response and scope it to the caller and endpoint, returning the saved result instead of charging again.
  3. 3Keys need a defined expiration window, and reuse after expiry must be treated as a new operation rather than a silent replay.
  4. 4Verification means automated tests that submit duplicates, cut the connection before and after commit, and replay old keys to confirm only one charge exists.

What an idempotency key actually promises

An idempotent operation is one where calling it multiple times has the same effect as calling it once. The HTTP specification treats PUT and DELETE as idempotent by design, which is why Stripe's engineers point out that a PUT to create a DNS record can be replayed safely: the server sees the duplicate and responds successfully without creating a second record (Stripe engineering). A charge is different. Calling a "create charge" endpoint twice creates two charges, and that is a real customer problem, not a rounding error.

An idempotency key narrows the gap. The client attaches a unique value to one business operation, and the server records that value alongside the result. If the same value arrives again, the server returns the recorded result instead of executing the operation a second time. The tutorial version of this idea frames it precisely: a timeout means "I don't know what happened," not "payment failed," so the client keeps the key and retries until it gets a definitive answer (The Double-Charge Trap).

That framing matters because it tells you what the key is not. It is not a lock, not a rate limiter, and not a substitute for a payment provider's own deduplication. It is a memory of intent, stored server-side, that survives network failures the client cannot observe.

The failure modes you are defending against are ordinary. A user double-clicks Pay. A mobile connection drops after the server commits but before the response arrives. An API gateway retries on a 504. A queue consumer processes the same event twice. As the same tutorial notes, in payment systems duplicate requests are expected rather than exceptional, so the backend must assume the same request can arrive more than once (The Double-Charge Trap).

Generating and scoping the key

The client generates the key, because only the client knows that two requests represent the same intent. Two common approaches:

  • A random UUID version 4, generated once when the payment attempt begins and persisted until the outcome is known. This is the simplest and the hardest to get wrong.
  • A deterministic hash of the request payload (order ID, amount, currency, customer). This deduplicates identical payloads even if the client loses its stored key, but it conflates two genuinely separate attempts that happen to match on those fields.

Stripe's documentation describes client-generated idempotency keys passed in an Idempotency-Key header, with the server checking whether it has processed that key before and returning the original response if so (Stripe API reference). A walkthrough of Stripe integration practice describes the same flow: the key travels in the header, the server compares it against prior requests, and a match short-circuits reprocessing (idempotency in Stripe integrations).

Whatever you generate, avoid Date.now() or Math.random() alone, since two different payments can collide on those values. Browser APIs provide crypto.randomUUID() for good reason.

Scoping is the part teams skip. A key should be unique within a namespace of (merchant or account, endpoint or operation type, key value). Without the endpoint in the scope, a key reused across /charges and /refunds would match the wrong record. Without the account, one tenant's key could collide with another's. The practical implementation is a unique database constraint on those columns together, which also gives you atomic deduplication for free when two identical requests race.

On the client side, storage choice matters. Session storage survives a page refresh but clears when the tab closes; local storage survives browser restarts, which is usually wrong for payments because stale keys linger on shared devices and leak across tabs. The comparison from the client-side walkthrough:

StorageSurvives refreshSurvives browser restartRecommendation
In-memory variableNoNoOnly for the current screen
sessionStorageYesNoRecommended for payment keys
localStorageYesYesAvoid for payments
CookieYesYesAvoid, sent on every request

The rule that ties it together: remove the stored key only when you know the final outcome, meaning a definitive success or a definitive decline. On a 409, a timeout, or a 5xx, keep it (The Double-Charge Trap).

Designing the server side: storage, states, and responses

The server needs a durable record per key with at least these fields: the scoped key, a request fingerprint, a state, the stored response, and timestamps for creation and expiry.

The state machine is small but load-bearing:

  1. Not found. Insert a row in a processing state under a unique constraint, then execute the charge.
  2. Processing. Another request with the same key is in flight. Return a conflict (409) so the client retries later rather than starting a second charge.
  3. Succeeded or declined. Return the stored response with the original status code. Do not re-execute.
  4. Key exists but the fingerprint differs. The same key was used for a different payload. Reject with a client error and a clear problem detail.

That fingerprint check is the one most implementations omit. Store a hash of the canonical request body (sorted keys, normalized amounts, no volatile fields) and compare on replay. If a client reuses a key for a different amount, you want a loud failure, not a silent charge.

Two more design rules:

  • Store the response, not just an outcome flag. Returning the original status code and body means the client's retry behaves exactly as if the first call had succeeded, which is what makes retries safe without client-side state machines.
  • Derive the downstream provider key from your own key. If you call a payment processor, pass a provider idempotency key derived from yours, and write it down before making the call. Then a crash between "provider charged" and "we saved the result" is recoverable: the retry hits the provider with the same key and gets the original charge back.

Error responses should be machine-readable. RFC 9457 defines a standard problem details format so clients do not have to parse bespoke error shapes, and it uses the application/problem+json media type (RFC 9457). That standard replaced the earlier RFC 7807, which is now obsolete (RFC 7807). Frameworks support it directly: Spring's ProblemDetail class renders RFC 9457 responses from exception handlers and sets the content type for you (Spring Framework error responses). The reasoning behind standardizing is that inconsistent error formats slow integration and increase maintenance cost (Problem Details, doing API errors well).

A key-conflict response might look like this:

HTTP/1.1 409 Conflict
Content-Type: application/problem+json

{
  "type": "https://api.example.com/problems/idempotency-in-progress",
  "title": "Request already in progress",
  "status": 409,
  "detail": "A request with this idempotency key is still being processed.",
  "instance": "/v1/payments/charge",
  "retryAfterSeconds": 2
}

Including Retry-After on the 409 gives clients a concrete backoff instruction instead of guesswork. Correct status codes are part of making an API easy to consume, and the Postman guidance on API design lists designing for idempotency and returning meaningful status codes as core practices (REST API best practices).

Expiration: how long to remember, and what happens after

Keys cannot live forever. Storage grows, and a key that never expires turns a client bug into a permanent denial of a legitimate payment.

Pick a TTL based on how long your clients actually retry. A common choice is 24 hours, which comfortably covers client retry loops, queue redelivery, and overnight batch reconciliation. Once a key expires, a replay must be treated as a new request, and your documentation should say so plainly. The Stripe integration write-up notes that keys carry a time-to-live and require a fresh key after expiry (Stripe integration idempotency).

Expiry introduces a subtle hazard. If a client's retry clock runs longer than your TTL, a retry after expiry creates a second charge. The fix is arithmetic, not hope: set the server TTL above the client's maximum retry window plus a safety margin, and make the client's retry policy explicit in the API docs. A worked example:

  • Client retries with backoff for at most 3 attempts at 1s, 2s and 4s, then polls status for 5 minutes.
  • Gateway and queue redelivery add up to 30 minutes.
  • Server TTL: 24 hours. Margin is enormous, which is the point.

For cleanup, a scheduled job deletes rows past expiry, or a partition-by-day table lets you drop whole partitions. Either way, expire on a timestamp column rather than a status flag, so purging is a single indexed delete.

Also decide whether expiry removes the key or keeps an audit copy. If your ledger or reconciliation process needs to prove that a duplicate was recognized and suppressed, keep the key and outcome in an append-only audit table even after the lookup row is purged.

Edge cases: partial failures, concurrency, and replay

Concurrency. Two requests with the same key arrive at the same millisecond. The unique constraint decides the winner; the loser reads the in-progress row and returns 409. Without the constraint, both insert and both charge. This is the single most important correctness property to verify, and it is exactly the scenario the client-side walkthrough describes when it says the insert must be atomic on user plus key (The Double-Charge Trap).

Partial failure after commit. The charge succeeded, the process died before writing the response. The row says processing. A reconciler must be able to resolve it: query the provider using the derived provider key, then write the outcome and serve it. Without the reconciler, those rows stay stuck until TTL and the client gets 409 forever.

Partial failure before commit. The charge was attempted but never authorized. The row should record the failure as a terminal state with the error response, so retries get the same failure immediately instead of hammering the provider.

Replay attacks. An idempotency key is not a capability. If an attacker captures a request with its key and replays it, your server returns the stored response, which may include sensitive payment data. Two mitigations: scope keys to the authenticated caller so a replay from a different credential misses the record, and require full authentication on replays just as on first calls. Consider also refusing replays outside a short window for read-back of responses containing card metadata.

Webhooks. Inbound webhooks have the same duplicate problem. Primary-key the provider's event ID and ignore repeats. This is a separate mechanism from outbound idempotency keys but the same idea.

Client-side double submit. Disabling the button is a usability nicety, not protection (The Double-Charge Trap). Assume the second request arrives anyway.

A verification test suite that proves one charge

Tests are where idempotency claims become facts. Build them as integration tests against a real database and a stubbed payment provider that counts charge calls.

The matrix:

TestSetupActionAssertion
Duplicate submitFresh keyPOST twice with same keyProvider called once, both responses identical
Timeout before commitProvider hangsPOST, drop connection, retryProvider called once
Timeout after commitProvider succeeds, response lostPOST, drop response, retryProvider called once, retry returns original
Concurrent raceTwo threads, same keyPOST simultaneouslyExactly one provider call, one 2xx and one 409
Payload mismatchSame key, different amountPOST twiceSecond returns 409 or 422, no second charge
After expiryTTL set to 1 secondPOST, wait, POST againSecond call treated as new operation
Replay from other accountValid key, different credentialPOST with foreign authRejected, no data leak

A minimal harness in Python using httpx and a counter stub:

import uuid, httpx

BASE = "http://localhost:8000/v1/charges"
KEY = str(uuid.uuid4())

def charge(amount=500):
    return httpx.post(
        BASE,
        json={"amount": amount, "currency": "usd"},
        headers={"Idempotency-Key": KEY},
        timeout=5,
    )

def test_duplicate_submit():
    first = charge()
    second = charge()
    assert first.status_code == 200
    assert second.status_code == 200
    assert first.json()["id"] == second.json()["id"]
    assert provider_stub.calls == 1

For the timeout-after-commit case, run the request with a very short client timeout while the stub sleeps past it, catch the exception, then retry with the same key and assert the charge ID matches the one the stub recorded. For the concurrency case, fire both requests from a thread pool and assert the multiset of status codes is {200, 409}.

Add a property-based test: generate random sequences of retries, cancellations and delays against a key, and assert the invariant that the provider call count equals the number of distinct keys that reached a terminal success. That single invariant catches most regressions.

Testing guidance from the API world converges on the same point: idempotent behavior belongs in the design contract, and tests should verify that retries do not create duplicates (Postman). Treat the matrix above as part of your definition of done for any mutating endpoint.

If you are building agents or coding assistants that call payment APIs, the same rules apply to the model-generated request flow, and a shared skill library can encode the key-generation and retry policy so every agent follows it: Quanta Skills lets you install a skill into your coding agent or hand the work to a specialist.

FAQ

FAQ

Where should the idempotency key be generated?

On the client, once per business operation, before the first request leaves. Store it alongside the operation until you have a definitive outcome, then delete it. A server-generated key cannot help, because the server only sees the retry as a fresh request.

What should the server return when the same key arrives twice?

The stored response from the original execution, including the same status code and body. If the original is still in flight, return 409 with a Retry-After header so the client backs off rather than starting a second charge. Never return an empty success that hides whether the charge happened.

How long should idempotency keys be kept?

Set a TTL that comfortably exceeds your slowest client retry path, and document it. Twenty-four hours is a common default because it covers client retries, queue redelivery and one reconciliation cycle. After expiry, a replay is a new operation, so the TTL must be long enough that this never surprises a legitimate retry.

What happens if two identical requests arrive at the same moment?

A unique database constraint on the scoped key ensures one insert wins and the other reads the in-progress row. The loser returns 409, the winner executes the charge. Any implementation without that atomic insert will eventually double-charge under load.

Is an idempotency key enough to prevent double charging?

It prevents duplicates from retries you can see, but you also need to derive the downstream provider key from your own so a crash between charge and save is recoverable, and you need a reconciler for rows stuck in progress. Test all three paths together, not just the simple duplicate submit.

Sources

Sources

  1. Designing robust and predictable APIs with idempotency - Stripe
  2. Idempotency Keys - The Double-Charge Trap
  3. Idempotent requests | Stripe API Reference
  4. Harnessing the Power of Idempotency Keys in Your Stripe Integration
  5. RFC 9457: Problem Details for HTTP APIs
  6. RFC 7807: Problem Details for HTTP APIs
  7. Error Responses :: Spring Framework
  8. Problem Details (RFC 9457): Doing API Errors Well - Swagger
  9. REST API Best Practices: A Developer's Guide to Building ...
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles