Payment Idempotency: How to Avoid Double Charges and Missing Orders
The hardest payment bug is not a failed charge. It is the charge that succeeded while every system around it believes it failed.
Situation
Modern checkout is a distributed workflow pretending to be a button click. A customer submits an order, the browser waits on an API, the API calls a payment processor, the processor talks to banks and card networks, and the commerce system creates inventory reservations, order records, receipts, fulfillment jobs, and customer notifications.
Every boundary can time out. The browser can retry. A mobile client can double-submit. A load balancer can drop the response after the payment provider commits the charge. A worker can crash after charging the card but before writing the order. A queue can redeliver the same message. A webhook can arrive before the synchronous API response.
The business promise is simple: charge once, create the order once, and never lose money or goods. The technical reality is that none of the participating systems can share one database transaction.
That gap is where idempotency belongs.
The Problem
A naive checkout flow treats each request as new work:
- Receive
POST /checkout - Create payment
- Create order
- Return success
That flow is fragile because retries are indistinguishable from duplicates. If the first request charges the card and the response is lost, the second request may charge again. If the first request creates the order but the payment confirmation is delayed, the second request may create a second order. If the application writes payment_succeeded after calling the processor but crashes before creating the order, support teams see the worst possible state: money captured, no order visible.
The deeper issue is that payment systems have at-least-once behavior at several layers. HTTP clients retry. Job queues redeliver. Payment webhooks are commonly retried until acknowledged. Databases can commit locally while remote calls remain unknowable. Exactly-once delivery is not the tool available to you; observable, recoverable once-only effects are.
The core question is: how do you design checkout so every retry converges on the same business outcome instead of repeating the side effect?
Core Concept
Idempotency is not a header. It is a server-side ledger that records the intent, parameters, state transitions, and final result for a business operation.
The client supplies an idempotency key for a logical checkout attempt. The server binds that key to a canonical request fingerprint, stores it before calling the payment provider, and returns the same result for every duplicate request with the same key. The order system uses the same discipline internally: unique constraints, state machines, and reconciliation workers make every step repeatable without multiplying side effects.
flowchart TD
A[client — checkout attempt] --> B[api — validate request]
B --> C[idempotency ledger — reserve key]
C --> D{ledger state}
D --> E[in progress — return pending]
D --> F[completed — return saved result]
D --> G[new — continue workflow]
G --> H[request fingerprint — compare parameters]
H --> I[payment provider — idempotent charge]
I --> J[orders database — unique order intent]
J --> K[outbox — fulfillment event]
K --> L[worker — repeatable delivery]
L --> M[customer — receipt and order]
I --> N[webhook handler — reconcile payment]
N --> J
A practical implementation has four records of truth, each with a narrow responsibility.
The idempotency ledger stores key, request_fingerprint, status, response_code, response_body, created_at, and expires_at. The first request inserts the key. Concurrent requests either wait, receive a 202 Accepted, or replay the stored response. A request with the same key but different parameters is rejected because it is not a retry; it is a collision.
The payment record stores the processor payment identifier, business order intent, amount, currency, and lifecycle state. It has a uniqueness constraint on the checkout intent or cart version that must not be charged twice.
The order record is created from a successful payment state, not from an optimistic assumption that the payment call will return cleanly. Its uniqueness constraint prevents duplicate orders for the same paid intent.
The outbox records downstream events in the same database transaction as the order state change. Fulfillment, email, analytics, and warehouse systems consume events at least once, so they also need idempotent handlers keyed by stable event identifiers.
The important move is to make retries boring. A duplicate request should do one of three things: return the original success, return the original failure, or report that the original operation is still being resolved. It should not perform another charge because the application is uncertain.
In Practice
Context: Stripe documents idempotent requests as a first-class API behavior: clients send an Idempotency-Key, and Stripe stores the resulting status code and body for that key, including failures, so retries receive the same result. Stripe also documents rejecting reuse when incoming parameters differ from the original request. See Stripe idempotent requests.
Action: The documented pattern is to generate a high-entropy key per logical operation, attach it to the payment creation request, and persist the application’s own operation record before issuing the external call. The application should not rely only on the provider’s key store, because order creation, inventory reservation, email, and fulfillment still happen in the application’s domain.
Result: The observable behavior becomes stable under network failure. If the provider creates the charge but the response is lost, the retried provider call returns the saved result for the same key. If the application receives the result twice through retries or webhooks, unique constraints and state transitions keep the order from being created twice.
Learning: Provider idempotency protects the provider side effect. Application idempotency protects the business side effect. You need both.
Context: PayPal’s API guidance also supports idempotency through a request identifier header for operations where duplicate calls must not create duplicate effects. See PayPal idempotency.
Action: The documented pattern is the same architectural shape: a caller supplies a stable request identifier, and the server uses it to identify retries of the same logical operation. Inside your own system, this maps naturally to a checkout_attempt_id, payment_attempt_id, or order_intent_id.
Result: The business flow can be retried from the client, API gateway, worker, or reconciliation process without changing meaning. A retry is no longer “do this again.” It becomes “tell me what happened to this attempt.”
Learning: Idempotency keys should represent business intent, not transport attempts. A new TCP connection, browser refresh, or queue delivery should not create a new charge unless the customer intentionally starts a new checkout attempt.
Context: PostgreSQL unique constraints and transactional writes provide the local enforcement mechanism. A unique index on idempotency_key, payment_attempt_id, or order_intent_id is a database-level guarantee that concurrent application processes cannot bypass.
Action: Use INSERT ... ON CONFLICT or equivalent transaction patterns to reserve work before external side effects. Store state transitions explicitly: started, payment_pending, payment_succeeded, order_created, failed, requires_reconciliation.
Result: Race conditions become database conflicts instead of duplicate charges. Recovery workers can scan incomplete states and ask the payment provider for the authoritative payment status.
Learning: The payment architecture should assume crashes between every two lines of code. Durable state before side effects and reconciliation after uncertainty are what make the system operable.
Where It Breaks
| Failure mode | What goes wrong | Control |
|---|---|---|
| Key generated per retry | Each retry looks new | Generate one key per checkout attempt and reuse it |
| No request fingerprint | Same key can hide different requests | Hash canonical amount, currency, cart, and customer intent |
| Provider idempotency only | Charge is safe but order can duplicate | Add application ledger and order uniqueness constraints |
| Synchronous flow only | Crash leaves payment without order | Add reconciliation from payment records and webhooks |
| Permanent key retention | Ledger grows without bound | Expire keys after business-safe windows and archive audit data |
| Cached failure forever | Transient internal error blocks checkout | Distinguish provider result replay from local retryable failure policy |
| Webhook treated as trusted sequence | Events arrive late or out of order | Fetch current provider state before final state transitions |
What to Do Next
- Problem: Your checkout path probably has more retry sources than you think: browsers, mobile clients, gateways, queues, workers, and webhooks.
- Solution: Introduce an idempotency ledger around the business operation, then enforce uniqueness at payment, order, and event boundaries.
- Proof: Verify by injecting timeouts after payment creation, crashing workers after database commits, replaying webhooks, and submitting the same checkout key concurrently.
- Action: Start with one invariant: for a given checkout attempt, there can be at most one successful charge and at most one created order. Put that invariant in the database, not just in application code.