Idempotency

View as Markdown

Network requests fail. A POST can time out after the server charged the card but before your code saw the response, so a blind retry would charge twice. Idempotency keys make a retried request return the original result instead of running again.

The Idempotency-Key header is REQUIRED, not optional, on every money-moving endpoint. Omit it and the request is rejected with 400 idempotency_key_required before anything is charged:

Endpoint
POST /v1/payment_intentscreate a Payment Element intent (names the amount)
POST /v1/chargescreate a charge
POST /v1/charges/:id/capturecapture an authorization
POST /v1/charges/:id/voidvoid an authorization
POST /v1/refundsrefund a charge
POST /v1/subscriptionscreate a subscription
POST /v1/3ds/sessionsopen a 3DS session
POST /v1/3ds/retry-declineretry a soft decline

Everything else is read-only or naturally safe to retry, and ignores the header. Confirming an intent (POST /payment_intents/:client_secret/confirm) does not take an idempotency key: the client secret already addresses one unpaid session.

On POST /payment_intents, POST /charges, POST /3ds/sessions and POST /3ds/retry-decline you may send the key in the JSON body as idempotency_key instead of the header. The header wins if both are present; one of the two must be there.


How to use it#

Send an Idempotency-Key header with a value you generate; a UUID v4 is ideal. Reuse the same key when you retry the same request:

bash
curl https://api.worthypayments.com/v1/charges \
  -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" \
  -H "x-api-key: sk_test_xxx" \
  -H "Idempotency-Key: 5f3b9c1e-7a2d-4e88-9f1a-2c4d6e8b0a11" \
  -H "Content-Type: application/json" \
  -d '{"amount":4999,"currency":"usd","card":{"number":"ev:...","cvv":"ev:...","exp_month":"12","exp_year":"30"}}'
  • First call with that key → runs normally, the result is stored.
  • Any retry with the same key → returns the stored response (same status, same body), with headers Idempotency-Replayed: true and X-Idempotency-Replayed: true (same value; the X- name is the documented alias). The card is not charged again. A request with no Chrome User-Agent never reaches this layer, Cloudflare answers 1010.

Always generate a fresh key per logical operation. Two genuinely different charges must use two different keys, or the second will replay the first.

Stable per order, not per click. Use something tied to the payment you mean once (your order id, cart id, or a UUID you store with that order). Do not call crypto.randomUUID() on every “Pay” press. A double-submit or network retry then becomes a second charge. The reference checkout derives the key from the order for this reason.


Scope and lifetime#

PropertyValue
HeaderIdempotency-Key (required)
Applies toPOST /payment_intents, POST /charges, POST /charges/:id/capture, POST /charges/:id/void, POST /refunds, POST /subscriptions, POST /3ds/sessions, POST /3ds/retry-decline
Stored for24 hours. After that the same key runs fresh
ScopePer API key. Your keys never collide with another merchant's
Replay markerIdempotency-Replayed: true and X-Idempotency-Replayed: true

The key is namespaced to your API key, so you can use any value scheme you like (order-1234-charge works as well as a UUID) without worrying about global collisions.


Concurrent retries#

If two requests with the same key arrive at the same time (e.g. an aggressive retry fires before the first finished), the second one gets a 409:

json
{
  "error": {
    "type": "invalid_request_error",
    "code": "idempotency_in_progress",
    "message": "Idempotency key is currently processing"
  }
}

This is your signal to wait a moment and retry. The first request is still running. Once it completes, retrying the same key replays its stored result.

The in-progress claim expires after 10 minutes. If the first request never settles (the process died mid-charge, the connection dropped before a result was recorded) the key stays locked for ten minutes and then becomes claimable again, so a stuck key cannot wedge an order forever. The tradeoff is the reason for the number: while the claim holds, a retry is correctly refused; once it lapses, a retry runs the request for real rather than replaying. Ten minutes is comfortably longer than any charge takes, so in practice the lapse only fires when the first attempt truly never completed.

Backing off on a 409 is therefore the right move, and giving up on it is not: keep retrying the same key with a short delay until you get a real answer. Switching to a fresh key after a 409 is the one thing that turns a protected retry into a double charge.


What gets replayed#

  • Success (2xx): stored and replayed for 24h, with one exception: a pending charge whose outcome is not known yet (see below).
  • A card decline (402) raised by the charge itself: stored and replayed, so a retry returns the same decline rather than attempting the card again. A gateway refusal (decline_code duplicate_transaction, processing_error, …) is a decline like any other and is replayed the same way.
  • A fraud block 400 (FRAUD_BLOCKED / failed_precondition), pre-screen or scored: stored and replayed exactly. A scored block is terminal under its key: the retry returns the same refusal naming the same failed charge, records no second charge and sends no second charge.failed webhook. A 400 without charge (a pre-screen block, or a scored block whose charge id was not returned) is replayed for 24 hours exactly as it was, still without charge, even if the failed charge was recorded and its webhooks fired. A retry must not come back as a different 402 card_declined. Trying again after a block takes a new key, which is a new attempt and is screened again. See Fraud blocks.
  • Server errors (5xx), 408, 409, 429: not stored. The key is released so a genuine retry can run the request again.
  • Other settled 4xx (the envelope the client actually received): stored and replayed. The edge no longer releases a DONE 4xx on a later null settle. That is what turned a 400 FRAUD_BLOCKED into a 402 on retry.

A pending charge is not a final answer#

POST /v1/charges answers status: "pending" when the gateway did not tell us in time whether the card was charged (a timeout or a broken connection after the request may have reached it). That pending is not stored under your key: the key is released at once.

  • Retry with the same key after a short delay. The payments service first asks the gateway what happened to that attempt. If it charged, you get that charge back (nothing is charged twice). If it provably did not, the retry runs the payment, once. While the answer cannot be established yet, the retry gets 409 idempotency_in_progress: wait and retry the same key again until you get a charge or a 402.
  • Never retry it under a new key. If the first attempt lands, that is a second charge.
  • You can also follow the charge by its id (GET /v1/charges/{id}, or the charge.succeeded / charge.failed webhooks): a pending charge resolves on its own within minutes.

Only this unknown outcome is released. A pending ACH debit (accepted by the bank network, settling over days) is a real answer and is stored and replayed for 24 hours as usual.


Reusing a key with a different body#

A key is bound to the request that claimed it. Send the same Idempotency-Key with a different body and the call is rejected with 400 idempotency_error. It does not replay the first response, and it does not run the second request.

The comparison is a hash of the fields that decide the money: for a charge that is amount, currency, capture mode and payment method; for a refund it is the whole body, so adding a reason to an otherwise identical retry counts as a different request. This matters if you key on your order id, which is otherwise good practice: an order whose amount changes (cart edit, tax recalculation) must claim a new key rather than reuse the old one.


Where the guarantee lives#

Re-running is safe rather than a second charge because the payments service keeps its own idempotency record keyed off the same request: it replays its stored outcome instead of authorizing again. The edge cache is a latency and consistency layer on top of that, not the only thing standing between you and a double charge.

One consequence worth planning for: the edge's replay cache is best-effort. If its store is unavailable the cache degrades to a per-process fallback, which does not span instances. The downstream record still holds, but you should not treat an edge replay as a guarantee you can build settlement logic on. Reconcile on the charge id.


The key is required#

On the money-moving POSTs listed at the top of this page the header is mandatory. Omitting it does not run the request unprotected. It is rejected outright, before anything is charged:

json
{
  "error": {
    "type": "invalid_request_error",
    "code": "idempotency_key_required",
    "message": "Idempotency-Key header is required for this request",
    "param": "Idempotency-Key"
  }
}

On POST /charges, POST /3ds/sessions and POST /3ds/retry-decline an idempotency_key field in the JSON body satisfies the requirement instead; the header wins when both are present.

Every other endpoint is read-only or naturally safe to retry, and ignores the header.


A second guard you do not control: the acquirer's own duplicate window#

Idempotency above is ours, and it is keyed on your Idempotency-Key. Your acquirer runs a second, independent check that is keyed on the payment itself (same card and same amount inside a window) and it does not know or care what key you sent. NMI's default window is 20 minutes. On NMI's sandbox the order reference is not part of the check: two charges with different order references on the same card and amount were still refused.

That means the two can disagree, and the case that surprises people is this one:

POST /v1/charges   Idempotency-Key: order_A   $9.11 on card ...1111   -> succeeded
POST /v1/charges   Idempotency-Key: order_B   $9.11 on card ...1111   -> failed

A new key, so our replay logic correctly treats it as a new payment, and the acquirer still refuses it as a duplicate. The second call comes back at once as a 402 with code: "card_declined", decline_code: "duplicate_transaction" and the gateway's own text in message (Duplicate transaction REFID:…). Read back, the charge is failed with outcome.network_status: "not_sent_to_network": the card network never saw it. Through the Payment Element the same refusal reaches onError as card_declined with declineCode: "duplicate_transaction". See Gateway refusals.

This is a protection, not a defect. It is what stops a double-submitted cart or a retrying job from charging a cardholder twice when the retry legitimately carries a different key. Treat it the same way you treat a decline: surface it, do not hammer it.

What to do about it:

  • Do not retry the identical charge to "get past" it. A retry inside the window fails the same way, and outside it you risk the second charge actually going through.
  • Know what a genuine second payment looks like to the acquirer. Two separate orders for the same amount on the same card are unusual but legitimate. A different order reference tells them apart only where the acquirer includes it in its check; NMI's sandbox does not, so the second is refused until the window passes.
  • Watch for it in testing. Firing the same fixture amount repeatedly against a sandbox is the fastest way to hit this, and it reads as "the money path broke" when nothing did. Vary the amount between test runs.

The window length and whether it can be tuned at all are set by the acquirer, not by this API, some refuse to let it be adjusted per charge. You cannot switch it off from the request.