Idempotency
Every POST in this API takes an Idempotency-Key, and it is the one thing standing between a retried request and a second charge to the buyer.
Send Idempotency-Key on every POST. A retry with the same key returns the original resource without creating a duplicate operation. The replay is signalled by the status code: the original create returns 201, while an idempotent replay returns 200. For POST /v1/payment_intents the status code is the only replay signal (the response body is byte-identical to the original). POST /v1/refunds and POST /v1/tokens additionally echo an idempotent: true field in the replay body. Choose keys that uniquely identify your server-side operation — <order_id>_<operation> is the convention used in this guide (ord_42_create, ord_42_capture, ord_42_refund). Generate keys server-side; never derive them from buyer-supplied input (cookies, query strings, request bodies).
A timeout or a 5xx is a retry of the same operation, and collapsing it is the entire job the key does. A key carrying an attempt or retry counter changes on every attempt, so the duplicate check never matches and the retry goes through as a fresh request: on POST /v1/payment_intents that is a second real charge, and on POST /v1/refunds a second real refund to the buyer.
Change the key only for an operation that is genuinely different — a deliberate second refund of the same payment (ord_42_refund_2), or a new order.
One exception, on the refund path: a refund the provider voided before it settled comes back 200 with status: "canceled" — terminal, no money returned — and reissuing it under the same key replays that response forever. That one needs a new key: a canceled refund is not a completed refund.
<order_id>_<operation> is the convention, and it has a sharp edge in the other direction. Any repeat of the same operation type on the same order needs its own suffix — not just a second full refund.
The case that bites: you issue a $5.00 partial refund on ord_42 as a shipping adjustment, then weeks later a second, unrelated $5.00 goodwill credit on the same order. Same key, same amount, same body — so the second call is a replay. It returns 200 with idempotent: true, and the buyer never receives the second $5.00. Your records say two refunds succeeded; their statement shows one.
Give the second one its own key (ord_42_partial_refund_2, or your support-ticket id), and read the idempotent field on every refund response before recording it as a fresh refund — see Refunds — the Refund response. A replay is not a failure, so nothing else will tell you.
On some routes the key is required, not advisory
Payment routes that keep no queryable record of their own reject a keyless charge with 400 idempotency_key_required — there the key is the only thing standing between a retry and a second charge. The identical keyless request fails identically; add a stable key and reuse it verbatim. Sending it on every POST means you never have to track which routes enforce it.
The three outcomes, and why two of them must not be retried the same way
Three responses look alike and mean opposite things — the split is not along the usual 4xx/5xx line.
| Response | What happened to the buyer | What to do |
|---|---|---|
400 idempotency_key_required | Nothing — refused before charging | Add a stable key and send again |
409 charge_in_progress | A charge for this key is already running or already done | Do not retry. Read the original payment's status |
503 service_unavailable | Nothing — the pre-charge record failed, nothing reached your provider | Retry shortly with the same key |
409 here is the one that can cost a buyer real money"Conflict" reads as collision — mint a fresh key and try again, and that is the single action that can double-charge: the earlier attempt may already have succeeded. On 409, read the original payment's status and never vary the key; if it persists, contact support with that Idempotency-Key.
await vonpay.paymentIntents.create(
{ amount: 1499, currency: "USD", captureMethod: "automatic" },
{ idempotencyKey: "ord_42_create" },
);
vonpay.payment_intents.create(
amount=1499,
currency="USD",
capture_method="automatic",
idempotency_key="ord_42_create",
)
-H "Idempotency-Key: ord_42_create"
Reusing a key with a different request body returns 422 idempotency_replay_incompatible rather than silently overwriting — a reason to reuse the key, not to vary it.