The timeout that charges twice
An idempotency header is only as strong as its scope, retention and concurrency rules. Crossing a provider or regional boundary can make an apparently safe replay new again.
7 min read
A customer clicks Pay. The payment service sends an authorization, the issuer approves it, and the success response disappears on the way back. Eight seconds later, the merchant sees a timeout. If the system labels that timeout “failed” and starts a fresh payment when the customer tries again, both authorizations can be valid. The timeout did not create the duplicate. Turning an unknown outcome into a known failure did.
The prevention rule is precise: one commercial intent gets one durable payment identity. A timeout moves that payment to UNKNOWN, never straight to FAILED. Every retry reuses the same operation key and unchanged request; every callback and status query updates the same record. A new charge is allowed only after the first attempt reaches an authoritative terminal state or is explicitly cancelled. That is the orchestration contract.
A missing response says nothing about the charge
Picture the failure as a timeline. At 10:00:00, a checkout service sends POST /payments for order 742. The processor accepts it and forwards the authorization. At 10:00:04, the issuer approves. At 10:00:08, a proxy closes the waiting connection. The caller knows only that it did not receive a response. It cannot infer where execution stopped.
HTTP makes the boundary explicit. It permits automatic repetition after communication failure for idempotent methods, but says a client should not automatically retry a non-idempotent method unless it knows the operation is idempotent or knows the first request was never applied.1 A payment-creation POST has neither property by default.
Payments documentation describes exactly this case. PayPal warns that a network timeout or 5xx from an execute or capture endpoint may already have created a transaction.2 Visa’s public rules require a reversal for an online financial transaction when the acquirer, merchant, or acceptance device did not receive the authorization response; they also address an approval that arrives after a point-of-sale timeout.3 Mastercard’s public processing rules similarly define full and partial reversals and matching them back to the original authorization.4 These mechanisms exist because response delivery and financial outcome are separate facts.
The application therefore needs three result classes, not two. SUCCEEDED means authoritative success. DECLINED or FAILED means authoritative non-execution. UNKNOWN means execution may have happened, but evidence has not arrived. A socket timeout, connection reset, truncated response, or ambiguous server error belongs in the third class unless the provider’s contract proves otherwise.
Make the intent durable before sending money
The payment record must exist before the outbound call. It needs a local payment ID, the order or invoice it serves, amount and currency, an operation key, a fingerprint of the request, provider references as they become available, and its current state. The database should enforce which records are allowed to represent the chargeable attempt for a business intent. That closes the race between a double-click, two workers, and an API-gateway retry before any of them reaches the processor.
The operation key is not a decorative request ID. It names one mutation. Stripe stores the first result for an idempotency key, including a 500, and rejects reuse when the parameters differ.5 Adyen says a timed-out payment call can be retried with the same idempotency key without performing the payment again; it also documents concurrent-request errors and a transient-error signal that controls whether to retry.6 PayPal’s equivalent header returns the latest status of the earlier request, while two simultaneous calls with the same key may cause the second to fail.7
Those differences matter. The orchestration layer must know each endpoint’s idempotency scope, retention window, concurrency behavior, and response semantics. PayPal notes that support and storage duration vary by API.7 Adyen documents that keys are scoped to a company account, retained for a bounded period, and not deduplicated across regional endpoints.6 “We sent a key” is not a guarantee unless the recovery attempt stays inside that contract.
Nor should one key represent “whatever happens to order 742.” Authorization, capture, void, and refund are different mutations and need different operation identities. Changing the amount while reusing a key changes the intent. A robust server stores the original parameters with the key and atomically records the key with the mutation; otherwise it can record the key without the effect, or create the effect without the record.12
Reconciliation, not repetition
After a timeout, the first recovery action depends on the provider contract. It may be a same-key retry, a retrieval of the existing payment object, or a cancellation by the original key. Stripe recommends reusing a PaymentIntent when checkout is interrupted and storing its ID on the cart or session so its state can be retrieved.8 Square exposes a cancellation endpoint specifically for a CreatePayment whose status is unknown after a network error; it identifies that payment using the original idempotency key.9
This is why UNKNOWN must be durable. It gives a reconciler something to own. The reconciler can retry with the same key where safe, query by provider reference, wait for a verified callback, or escalate an aged record for operational review. It must never manufacture a new key merely because a retry budget expired. Backoff protects an unhealthy dependency; it does not resolve the financial outcome.
Callbacks are evidence, not a shortcut around the state machine. Stripe says webhook events can arrive more than once and are not guaranteed to arrive in generation order.10 Adyen also documents duplicate webhook events and recommends using stable payment references and event details to handle them.11 Callback handlers therefore need their own deduplication and must apply transitions conditionally. A late “processing” event cannot overwrite SUCCEEDED; two deliveries of “captured” cannot trigger fulfillment twice.
The synchronous response, status poll, callback, and later reconciliation are four observations of one payment—not four opportunities to execute it. Their job is to converge the local record on the best authoritative state.
Put one component in charge of the side effect
Retries become dangerous when ownership is diffuse. The browser retries the checkout request. The edge proxy retries the browser’s POST. The checkout API republishes a job. The worker retries the processor. If each layer invents a new key, the stack has retry logic but no shared identity.
Only one orchestration boundary should be allowed to initiate or recover the payment mutation. Upstream callers submit the business intent and receive the existing payment state. Downstream retries are issued by that owner with the persisted key. Infrastructure retries for mutating calls are either disabled or configured to preserve the operation identity and provider-specific safety rules. The customer may press Pay again, but that action first retrieves or resumes the payment already attached to the order.
That identity has to survive the exact crash it is meant to tame. If the process receives success from the provider and dies before committing locally, its replacement must find the persisted attempt and recover it with the same key. An idempotency key generated only in memory disappears at the most dangerous moment. Durable dispatch and conditional updates make recovery part of the original operation, not a new one.
The user experience should reflect the same truth. An uncertain attempt is “payment processing,” not “payment failed.” The order should not be fulfilled yet, but neither should the interface invite a fresh charge. Certainty may take longer; duplicate prevention is the trade.
Test the gap where the response disappears
The decisive test is not “the provider returned 200.” Inject a failure after the provider has accepted the mutation but before the application commits the response. Then replay the customer request, restart the worker, deliver the success callback twice, deliver it before the synchronous response, and run the reconciler concurrently. Repeat the test after the provider’s idempotency-retention window and with a changed amount.
One invariant should survive every schedule: a single business intent can produce at most one active, chargeable payment attempt. Every observation must converge on that attempt, and any replacement must follow an explicit terminal failure or cancellation of its predecessor.
There is no response code that supplies this guarantee. It comes from durable identity, an honest UNKNOWN state, provider-aware recovery, and conditional state transitions. A timeout can hide a charge. Only orchestration prevents the hidden charge from becoming two.
Sources
- RFC 9110: HTTP Semantics (opens in a new tab), Internet Engineering Task Force.
- API responses (opens in a new tab), PayPal Developer.
- Visa Core Rules and Visa Product and Service Rules (opens in a new tab), Visa.
- Transaction Processing Rules (opens in a new tab), Mastercard.
- Idempotent requests (opens in a new tab), Stripe Documentation.
- API idempotency (opens in a new tab), Adyen Docs.
- Idempotency (opens in a new tab), PayPal Developer.
- The Payment Intents API (opens in a new tab), Stripe Documentation.
- Cancel payment by idempotency key (opens in a new tab), Square Developer.
- Receive Stripe events in your webhook endpoint (opens in a new tab), Stripe Documentation.
- Handle webhook events (opens in a new tab), Adyen Docs.
- Making retries safe with idempotent APIs (opens in a new tab), Amazon Builders’ Library.