The short answer
Quick answer: An operation is idempotent if doing it several times has the same effect as doing it once. Payments are not naturally idempotent: sending "charge £50" twice charges £100. Payment APIs fix this with an idempotency key: the client generates a unique ID for each intended payment and sends it with the request. The server records the key along with the result. If the same key arrives again, because of a retry, a timeout or a double click, the server does not charge again. It returns the saved result of the first attempt.
Why duplicates happen
You tap "Pay". The app sends the request. Then one of these occurs:
- The request never reached the server.
- The server received it and failed before charging.
- The server charged the card, but the response was lost on the way back.
From the app's side, all three look identical: a timeout. It cannot tell whether the payment happened.
- If it does not retry, the customer may never be charged in cases 1 and 2.
- If it does retry, the customer is charged twice in case 3.
This uncertainty cannot be removed. It is a basic property of networks, illustrated by the Two Generals' Problem. And retries come from everywhere: impatient users pressing the button again, automatic retries in HTTP libraries and proxies, and message queues that deliver at least once.
Since you cannot avoid retries, you make retries safe.
What idempotent means
| Operation | Idempotent? |
|---|---|
| "Set the balance to £100" | Yes |
| "Add £10 to the balance" | No |
| "Delete order 42" | Yes |
| "Create a new order" | No |
HTTP defines this for its methods in RFC 9110:
| Method | Idempotent by definition |
|---|---|
| GET, HEAD | Yes (and safe: no changes) |
| PUT | Yes: replace with this state |
| DELETE | Yes |
| POST | No |
| PATCH | Not necessarily |
Creating a payment is a POST. So it needs something extra.
Idempotency keys
The pattern, popularised by Stripe and described in its post on designing APIs with idempotency:
- The client generates a unique key, typically a random UUID, once per intended operation.
- It sends the key in a header with the request.
- On any retry of that same operation, it sends the same key.
POST /v1/payments
Idempotency-Key: 7c1f0b9e-3a52-4d0e-9a41-5d2f1e8b6c33
Content-Type: application/json
{ "amount": 5000, "currency": "gbp", "customer": "cus_123" }
The Idempotency-Key header is being standardised for this purpose.
The key identifies the customer's intent. A new intent ("buy this again") gets a new key. A retry of the same intent reuses the old one.
What the server does
- Look up the key.
- Not seen before: record the key as "in progress", perform the operation, save the response with the key, and return it.
- Seen and completed: return the saved response. Do nothing else.
- Seen and still in progress: another request with this key is running. Return a conflict error, or wait for it.
- Seen with different parameters: the client reused a key for a different request. Reject it.
Getting the details right
- The check must be atomic. Two identical requests arriving at the same instant must not both pass the "not seen" check. Enforce it with a unique constraint on the key in the database: the second insert fails. See how databases handle concurrent transactions.
- Store the key in the same transaction as the business change. If the charge record and the key are committed together, you can never have one without the other.
- Fingerprint the request. Save a hash of the parameters to detect a key reused with different data.
- Save the response, including errors that are final. A declined card should be declined again on retry, not re-attempted. Temporary server errors, on the other hand, should allow a retry.
- Scope keys to the account or API key.
- Expire keys after a sensible period, such as 24 hours, so storage does not grow without limit.
Calls to other systems
A payment service usually calls a card network or bank, which is itself an unreliable network call. If your server crashes after the bank charged the card but before you recorded it, a retry could charge again.
So idempotency must be carried all the way down: pass an idempotency key or unique reference to the downstream provider too. Then your retry to them is also safe.
Larger flows are modelled as a state machine with recorded steps (created, authorised, captured, completed). After a crash, a retry looks up how far the operation got and resumes from there instead of starting over.
Other ways to achieve idempotency
- Natural keys. A unique constraint on something meaningful, such as one payment per order ID.
- Conditional updates. "Mark as paid only if currently unpaid":
UPDATE orders SET status = 'paid' WHERE id = 42 AND status = 'pending'. - Deduplication tables in queue consumers: record each processed message ID and skip repeats.
- Design operations as "set" not "add". Absolute values are idempotent; increments are not.
Idempotency and "exactly once"
You cannot guarantee that a message is delivered exactly once over an unreliable network. What you can do is:
at-least-once delivery + idempotent processing = exactly-once effect
Retry until you get an answer, and make sure repeats are harmless. Every reliable payment, messaging and order system is built on this combination.
Client-side good practice
- Generate the key when the user starts the action, for example when the checkout page loads, not on each click.
- Persist it so a retry after an app restart uses the same key.
- Retry with exponential backoff and jitter, and respect rate limits.
- Disable the button after the first click. It improves the experience, but it is not a substitute for server-side idempotency.
Beyond duplicates: reconciliation
Even with all of this, payment companies do not rely on a single mechanism. They keep an append-only ledger of every money movement and regularly reconcile their records against bank and card network statements. Any mismatch is investigated. Idempotency prevents most duplicates; reconciliation catches whatever slipped through. Amounts are stored as integers in the smallest currency unit, never as floats; see why 0.1 + 0.2 is not 0.3.
Frequently asked questions
What is an idempotency key?
A unique value the client attaches to a request so the server can recognise retries of the same operation and avoid performing it twice.
Who generates the idempotency key?
The client. Only the client knows whether two requests represent the same intent or two separate ones.
Is POST idempotent?
Not by default. An idempotency key makes a specific POST endpoint safe to retry.
What happens if I reuse a key with a different request?
A well-designed API rejects it with an error, because the saved result belongs to a different request.
Conclusion
Networks make it impossible to know whether a timed-out request succeeded, so retries are unavoidable. Idempotency keys make them harmless: the client names its intent, and the server performs each named intent once and replays the answer thereafter. It is a small pattern that every API changing money, inventory or anything else that matters should adopt.
Related articles
- The Two Generals Problem: Why Perfect Communication Is Impossible
- How Message Queues Like Kafka Decouple Systems
- How Database Transactions Handle Two Users Editing at Once
- How Rate Limiters Protect APIs From Abuse
