Idempotency in Payment APIs: Surviving Retries Without Double Charges

Idempotency in Payment APIs: Surviving Retries Without Double Charges
In payment-orchestration engagements, one bug shows up more than any other: a client retries a charge after a timeout, and the customer gets billed twice. It's the default outcome of naive retry logic on a network that occasionally drops a connection between the moment a server finishes processing and the moment its response reaches the client.
The retry problem
A payment request travels several hops — your server, the gateway, often an acquiring bank, then back. Any of them can time out after the charge already succeeded on the far end. A timeout and a genuine failure look identical to the client: both are the absence of a response.
Think of an ATM screen that freezes before printing the receipt. Nobody knows whether the money left the account or not — that's exactly the position a payment API is in during a timeout. The client can't tell "this never happened" from "this happened, and I just never heard back."
Retrying is still the right instinct; refusing to retry leaves transient failures stuck on a spinner. The problem is retrying blindly, as though the first attempt is guaranteed not to have landed. That gap between "I don't know" and "it already happened" is where double charges come from.
Stripe's own explanation for why it built idempotency into its API names the failure mode directly: for an endpoint that charges a customer, "accidentally calling it twice would lead to the customer being double-charged, which is very bad".
The HTTP semantics gap
Part of why this keeps recurring is that HTTP's own vocabulary works against you. MDN's definition of an idempotent method is one where several identical requests have the same effect as one. It states plainly that "the POST and PATCH methods are not guaranteed to be idempotent."
Its example: a repeated POST /add_row creates a new row each time instead of converging on one state. A charge endpoint is a POST; nothing in HTTP promises firing it twice is safe. The IETF HTTPAPI working group's draft standard says it formally: POST and PATCH aren't idempotent under RFC 9110, unlike GET, PUT, DELETE, HEAD, and OPTIONS.
That's exactly why a dedicated Idempotency-Key request header exists. Attach it to a mutating call, and the server reads a retry as a retry, not a fresh instruction to charge the card again.
How the major providers implement it
The concept is shared; the specifics aren't.
| Provider | Header | Key format / limit | TTL | Duplicate-in-flight behavior |
|---|---|---|---|---|
| Stripe | Idempotency-Key | V4 UUID recommended, max 255 chars | Min. 24 hours, then pruned | Replays the cached status code and body, including a stored 500 |
| Adyen | idempotency-key | UUID recommended, max 64 chars | Min. 7 days | HTTP 409/422, error code 704: "request already processed or in progress" |
| Midtrans | Idempotency-Key | Max 46 chars (longer values ignored) | 5 minutes | HTTP 202 while the original is still processing |
| Xendit | idempotency-key | Client-defined string | Documented per endpoint | DUPLICATE_ERROR if the key is reused with different parameters |
Stripe scopes keys to POST only — GET and DELETE are already idempotent by definition. Adyen's week-long minimum retention is seven times Stripe's floor. Midtrans's five-minute window is tight enough to shape your retry backoff, below.
Building your own idempotency layer
Exposing a payment endpoint yourself means making the same design choices your vendors already made.
Cache the first response. A key doesn't just block a duplicate — it answers one with the same result the original produced. Store the response body and status against the key; return it unchanged on a repeat.
Pick an expiry window on purpose. Too short, and a client retrying after a slow-but-successful attempt creates a genuine second charge. Too long, and you're holding payloads indefinitely for keys nobody reuses. Stripe's 24-hour and Adyen's 7-day floors reflect different assumptions about how long a client might wait before giving up.
Handle the in-flight race explicitly. A key can arrive twice while the first request still runs. Queuing it silently, or letting both run, is how double charges happen even with keys in place — the IETF draft recommends a 409 conflict here; Adyen's 409/422 and Midtrans's 202 are production answers to the same problem:
Scoping keys correctly
A single global namespace for idempotency keys is a mistake I see repeatedly. On a multi-tenant platform, two merchants can generate the same key value, and one tenant's retry lands on another's request. The IETF draft's guidance is to build the cache-lookup key as a composite: the client-supplied key plus an attribute only the server knows, such as account or merchant identity.
The same discipline applies to the request body. Stripe and Xendit both treat a reused key with different parameters as an error, not a silent overwrite — Stripe rejects the mismatch, and Xendit returns a DUPLICATE_ERROR telling the caller to generate a new one. A changed amount or recipient under a reused key is more likely a caller bug than an intentional retry.
Indonesia-specific gotchas
Local rails add constraints global providers don't share. Midtrans's 5-minute window is short next to Adyen's week-long floor, and unsupported for Permata Virtual Account, CIMB Clicks, KlikBCA, and Indomaret — your retry safety net has gaps by channel, not only by time. Offer those methods, and your own idempotent order reference has to cover the gap.
Xendit's payout guidance is simply to reuse the same key on any retry of a failed or timed-out payout, worth testing before go-live.
QRIS and virtual-account flows compound this: final confirmation usually arrives asynchronously, as a callback, well after the initial API call returns. The gateway's key protects that initial request; it does nothing for the callback. You still need your own idempotent order ID at the merchant layer, a constraint that makes processing one callback twice a no-op — the same discipline that keeps a hotel from double-assigning one room when two booking confirmations race each other.
Before you sign, and an audit for this week
Demand four things from any payment vendor: idempotency on every mutating endpoint, not just charges; a published TTL and key-scope policy; a deterministic response for an in-flight retry; and visibility into your own 409 conflict rate. Then run the same checklist on your own stack:
- Confirm every mutating payment endpoint you call or expose accepts an idempotency key.
- Check your retry logic reuses one key per attempt — a fresh key each call defeats the mechanism.
- Confirm your key scope includes merchant or account identity, not just the raw string.
- For any channel your gateway excludes, verify your own idempotent order ID at the merchant layer.
- Pull your 409/422 conflict rate for the last 30 days — near zero on a high-retry system is worth investigating.
Idempotency keys are a small piece of API surface. Getting them wrong is one of the most expensive mistakes in a payment integration — and one of the cheapest to prevent.
Related Posts
Building something similar?
IoT Backend & Multi-Protocol Integration
Backends that ingest device telemetry across MQTT, WebSocket, Modbus, and BLE, and normalize it into reliable real-time dashboards.
See how I can help