Tunas Akara
Back to Blog

Payment API Idempotency: Avoiding Double Charges

Idempotency keys stop retried payment requests from double-charging customers. How Stripe, Adyen, Midtrans, and Xendit each implement them.

by RayhanUpdated 8 min read

For Technical Teams

paymentsidempotencyapi-designfintech
Payment API Idempotency: Avoiding Double Charges
On this page

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. Idempotency means repeated requests for one operation have the same effect as a single request. Double charging can result from 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.

ProviderHeaderKey format / limitTTL (key retention)Duplicate-request behavior
StripeIdempotency-KeyV4 UUID recommended, max 255 charsAt least 24 hours; eligible for pruning afterwardReplays the cached status code and body, including a stored 500
Adyenidempotency-keyUUID recommended, max 64 chars7–14 daysReturns the first response after completion; HTTP 409/422 with code 704 while still processing
MidtransIdempotency-KeyMax 46 chars (longer values ignored)5 minutesHTTP 202 while the original is still processing
Xenditidempotency-keyClient-defined stringDocumented per endpointDUPLICATE_ERROR can indicate an already-used key; reconcile the existing payout before changing keys

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. A duplicate must wait or be rejected without starting a second operation. Letting both run can cause double charges 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:

Loading diagram…

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 rejects a reused key with different parameters. Xendit documents DUPLICATE_ERROR for an already-used payout key; that error alone does not establish that the parameters changed. Reconcile the existing payout before creating another key. 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:

  1. Confirm every mutating payment endpoint you call or expose accepts an idempotency key.
  2. Check your retry logic reuses one key for every attempt at the same logical operation — a fresh key each call defeats the mechanism.
  3. Confirm your key scope includes merchant or account identity, not just the raw string.
  4. For any channel your gateway excludes, verify your own idempotent order ID at the merchant layer.
  5. Pull your 409/422 conflict rate for the last 30 days — interpret it against the provider’s replay behavior. Successful duplicate requests may return the original response without a conflict.

Idempotency keys are one part of a payment API design. Consistent transaction identity, stored results, and reconciliation help prevent costly billing errors.

The diagram is conceptual: lookup followed by an unprotected insert is not safe. Claim the key atomically with a unique account-scoped constraint, store a payload fingerprint, and retain an uncertain state if the provider may have completed before the connection failed. Reconcile provider status before starting a new attempt.

What should the business owner test?

Request demonstrations of a lost payment response, a double-click on pay, and a repeated callback. Each must leave one traceable transaction. Ask when keys expire and how the team confirms provider status before retrying.

Related Posts

Need help applying this approach?

Payment & API Integration

Connect payment services to your business systems. Plan transaction tracking, payment issue handling, and reconciliation of operational records.

Explore related services

Describe the operational problem you are facing so we can discuss a suitable next step.

Share this article
Free Initial Discussion