# Idempotency

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_intents` | create a Payment Element intent (names the amount) |
> | `POST /v1/charges` | create a charge |
> | `POST /v1/charges/:id/capture` | capture an authorization |
> | `POST /v1/charges/:id/void` | void an authorization |
> | `POST /v1/refunds` | refund a charge |
> | `POST /v1/subscriptions` | create a subscription |
> | `POST /v1/3ds/sessions` | open a 3DS session |
> | `POST /v1/3ds/retry-decline` | retry 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](/demos/checkout-reference.html)
> derives the key from the order for this reason.

---

## Scope and lifetime

| Property | Value |
|---|---|
| Header | `Idempotency-Key` (required) |
| Applies to | `POST /payment_intents`, `POST /charges`, `POST /charges/:id/capture`, `POST /charges/:id/void`, `POST /refunds`, `POST /subscriptions`, `POST /3ds/sessions`, `POST /3ds/retry-decline` |
| Stored for | **24 hours**. After that the same key runs fresh |
| Scope | Per API key. Your keys never collide with another merchant's |
| Replay marker | `Idempotency-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-pending-charge-is-not-a-final-answer)).
- **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](./10-errors.md#fraud-blocks-fraud_blocked).
- **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](./10-errors.md#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.
