# Errors

Every error from `https://api.worthypayments.com/v1` is returned as a single, predictable
envelope, and the **HTTP status always matches the error**. A declined charge is a `402`,
not a `2xx` with a hidden error. Branch on the HTTP status and `code`; for card declines,
also read `decline_code`.

```json
{
  "error": {
    "type": "card_error",
    "code": "card_declined",
    "decline_code": "insufficient_funds",
    "message": "The charge was declined."
  }
}
```

| Field | Always present | Notes |
|-------|:---:|-------|
| `type` | yes | The error category. See the table below. |
| `code` | yes | A stable machine-readable reason within the category. |
| `decline_code` | sometimes | For card declines: the issuer's specific reason when the issuer returned one, or the gateway's reason when the gateway refused the payment (see [Gateway refusals](#gateway-refusals)). |
| `message` | yes | Human-readable. Safe to show (or adapt) for the shopper. On a gateway refusal it is the gateway's own text, with its reference (`REFID:…`): log it, and show the shopper your own wording. |

> A **declined charge returns HTTP `402`** and a **failed refund returns HTTP `400`**,
> both with the envelope above. You can branch on the status code alone; you don't need to
> inspect a success body for a hidden decline.

---

## Error types

| `type` | Meaning | HTTP |
|--------|---------|:---:|
| `invalid_request_error` | Bad input, missing/invalid field, raw PAN, missing key, missing scope, unknown resource, idempotency conflict. | `400` / `401` / `403` / `404` / `409` |
| `card_error` | The card was declined by the issuer or the acquirer, or the gateway refused the payment before it reached the card network. | `402` |
| `rate_limit_error` | Too many requests. | `429` |
| `api_error` | A transient server-side problem. Internal details are never leaked. | `500` / `502` / `503` |

---

## HTTP status map

| Status | `type` | `code` | When |
|--------|--------|--------|------|
| `400` | `invalid_request_error` | `parameter_invalid` | Edge validation failed (charge amount < 50, capture/refund amount not a whole number ≥ 1, bad currency, missing field) |
| `400` | `invalid_request_error` | `parameter_unknown` | A top-level field this route does not accept. Refused, not ignored. Typical: `receipt_email` / `billing` / `order_reference` on `POST /v1/charges` (see [Charges](./03-charges.md).) |
| `400` | `invalid_request_error` | `idempotency_key_required` | `Idempotency-Key` missing on a money-moving endpoint. See [Idempotency](./12-idempotency.md) |
| `400` | `invalid_request_error` | `idempotency_error` | Same key reused with a **different** request body |
| `400` | `invalid_request_error` | `currency_not_supported` | Currency not enabled for this account |
| `400` | `invalid_request_error` | `test_mode_only` | Endpoint is test-mode only, called with a live key |
| `400` | `invalid_request_error` | `email_required` / `ip_required` / `invalid_action` | Required field missing on a fraud endpoint |
| `400` | `invalid_request_error` | `raw_pan_rejected` / `pan_encryption_required` | A raw card number reached the API. Send an `ev:` token |
| `401` | `invalid_request_error` | `api_key_required` | No API key on the request |
| `401` | `invalid_request_error` | `api_key_invalid` | Key unknown, expired, or revoked |
| `401` | `invalid_request_error` | `hmac_headers_missing` / `hmac_invalid` / `hmac_nonce_reuse` / `hmac_timestamp_skew` | Request signing failed. See [Authentication](./02-authentication.md) |
| `403` | `invalid_request_error` | `account_not_activated` | Live key on an account that has not completed onboarding |
| `403` | `invalid_request_error` | `sandbox_key_required` | Test mode only: a call that reaches the gateway (charge, intent confirm, saved card, capture, void, refund) on an account with no NMI sandbox key of its own. Add it in the dashboard (Settings > Integrations, NMI card, in test mode); no sandbox account yet: create one at [guide.nmi.com](https://guide.nmi.com/). The refusal is not stored under the `Idempotency-Key`, so retry the same request once the key is saved. See [Testing](./15-testing.md#your-own-nmi-sandbox-key) |
| `403` | `invalid_request_error` | `mode_not_allowed` | Key is fenced to a different execution mode |
| `403` | `invalid_request_error` | `domain_not_allowed` | The request's `Origin`/`Referer` is not on the key's allowlist, or a browser key arrived with no `Origin` at all. Update the list on the key without rolling the secret; see [Quickstart step 2](./01-quickstart.md#2-allowlist-your-domains) |
| `402` | `card_error` | `card_declined` (+ `decline_code`) | The card was declined, or the gateway refused the payment ([Gateway refusals](#gateway-refusals)). Also a capture the gateway refused: the authorization is still held |
| `400` | `invalid_request_error` | `refund_declined` (+ `decline_code`) | The gateway refused the refund. No money moved; `message` is the gateway's reason |
| `400` | `invalid_request_error` | `void_declined` (+ `decline_code`) | The gateway refused the void. The authorization is still held |
| `403` | `invalid_request_error` | `permission_denied` | Valid key, missing scope (or `pk_` on a server-only action) |
| `404` | `invalid_request_error` | `resource_missing` | Unknown charge / refund id |
| `400` | `invalid_request_error` | `resource_missing` (`param: customer`) | `POST /v1/payment_intents` named a `customer` this key's merchant and mode do not hold (unknown, deleted, or another account's or mode's). `message` is `No such customer: '<the id you sent>'`. The same `Idempotency-Key` replays this 400; correct the id and use a new key |
| `400` | `invalid_request_error` | `failed_precondition` | The charge is not in a state that allows this follow-up. See [Capture, void and refund errors](#capture-void-and-refund-errors) |
| `400` | `invalid_request_error` | `failed_precondition` (`FRAUD_BLOCKED`) | Our fraud screening (pre-screen velocity or the scored fraud screen) refused the charge before the issuer. A scored block also names the failed charge in `charge`. See [Fraud blocks](#fraud-blocks-fraud_blocked) |
| `400` | `invalid_request_error` | `invalid_argument` | A follow-up amount the charge cannot satisfy (see the same table) |
| `400` | `invalid_request_error` | `three_ds_amount_mismatch` | A 3-D Secure resubmit whose amount or currency differs from what the cardholder authenticated (`param: three_ds.session_id`). Nothing was charged; run a new challenge. See [3-D Secure](./07-3ds.md) |
| `409` | `invalid_request_error` | `aborted` | Another capture or void is already in flight for this charge, retry after it settles |
| `409` | `invalid_request_error` | `idempotency_in_progress` | A request with the same `Idempotency-Key` is still running |
| `402` | `card_error` | `card_declined` (`decline_code` `three_ds_failed` / `three_ds_card_not_enrolled`) | The `three_ds.session_id` session did not authenticate (`param: three_ds.session_id`). Nothing was charged; the refusal is stored under the key. See [3-D Secure](./07-3ds.md#a-session-that-did-not-authenticate) |
| `409` | `invalid_request_error` | `three_ds_session_incomplete` | The `three_ds.session_id` challenge has not finished. Nothing was charged and the key is not used up: resubmit with the same key once it does |
| `503` | `api_error` | `three_ds_result_unavailable` | The `three_ds.session_id` session authenticated but its result could not be read just now. Nothing was charged; retry with the same key |
| `400` | `invalid_request_error` | `parameter_invalid` (`param: three_ds.request`) | An unknown `three_ds.request`, or a request on an instrument 3DS cannot run on (saved card, gateway vault id, `wp:` envelope, wallet). See [3-D Secure](./07-3ds.md#asking-for-3ds) |
| `429` | `rate_limit_error` | `rate_limit_exceeded` | Rate limit hit. See `Retry-After` |
| `500` | `api_error` | `internal_error` | Unexpected server error |
| Cloudflare `1010` | — | — | No browser `User-Agent`. **Not** this JSON envelope. An HTML interstitial. See [Cloudflare 1010](#cloudflare-1010) |
| HTML `403` (loopback in the URL) | — | — | `?origin=http://localhost…` or `?parent=http://localhost…` on the public edge. **Not** this JSON envelope. Local keys still work, send `Origin` and omit the query. See [Loopback in the URL](#loopback-in-the-url) |

---

## Common authentication & permission errors

```jsonc
// 401 — missing key
{ "error": { "type": "invalid_request_error", "code": "api_key_required",
             "message": "Missing API key" } }

// 401 — invalid / expired / revoked / empty principal
{ "error": { "type": "invalid_request_error", "code": "api_key_invalid",
             "message": "Invalid API key" } }

// 403 — missing scope (same shape for secret without the grant, or pk_ on a server-only route)
{ "error": { "type": "invalid_request_error", "code": "permission_denied",
             "message": "API key missing scope checkout_links:create" } }
```

Branch on `code`, not on the exact `message` string, prose may be tightened without a
version bump.

See [Authentication](./02-authentication.md) for the scope rules.

---

## Common request errors

```jsonc
// 400 — raw card sent instead of an ev: token
{ "error": { "type": "invalid_request_error", "code": "parameter_invalid",
  "message": "Card number and CVV must be Evervault tokens (ev:...). Tokenize in-page with the browser SDK; raw card data is never accepted." } }

// 404 — unknown charge
{ "error": { "type": "invalid_request_error", "code": "resource_missing", "message": "No such charge." } }

// 400 — POST /v1/payment_intents named a customer this key's merchant and mode do not hold
{ "error": { "type": "invalid_request_error", "code": "resource_missing", "param": "customer",
  "message": "No such customer: 'cus_5d87e946-1c2b-4d3e-8f40-a1b2c3d4e5f6'" } }

// 409 — concurrent retry with the same Idempotency-Key
{ "error": { "type": "invalid_request_error", "code": "idempotency_in_progress",
  "message": "Idempotency key is currently processing" } }
```

### Risk decision token

These appear only when you sent a token. A charge without the field never produces them.

| Code | Cause | What to do |
|------|-------|------------|
| `risk_decision_token_invalid` | Bad signature, unknown format, or issued to a different account | Do not retry with this token. Call `/check` again. |
| `risk_decision_token_expired` | Older than 15 minutes | Call `/check` again. |
| `risk_decision_token_amount_mismatch` | Charge amount or currency differs from the evaluated one | Charge the evaluated amount, or re-evaluate with `/check/recheck`. |
| `risk_decision_token_card_mismatch` | A different card than the one evaluated | Re-evaluate for the card you are charging. |
| `risk_decision_token_device_mismatch` | A different device fingerprint | Send the same fingerprint you sent to `/check`. |
| `risk_decision_token_reused` | Already spent; a token is single use | Call `/check` again. |

All of the above are `400 invalid_request_error` with `param: "risk_decision_token"`.

Two that are not:

- A token carrying a **`deny`** verdict declines the charge as `402 card_declined`
  (`type: "card_error"`). You did what the API asked and the answer was no.
- **`503 service_degraded`** means token verification is temporarily unavailable. Retry
  **without** the token: the charge still runs the full fraud engine, so this costs you
  provenance, never the sale.

---

## Common card declines

Returned with `type: "card_error"` and HTTP `402` on a charge. `decline_code` is the
issuer's specific reason when available.

| `code` | `decline_code` | Retry? |
|--------|----------------|:---:|
| `card_declined` | `insufficient_funds` | New card |
| `card_declined` | `do_not_honor` | New card |
| `card_declined` | `security_violation` | New card |
| `card_declined` | `pickup_card` | No, do not retry |
| `card_declined` | `fraudulent` | No, do not retry |

> **In test mode, decline with the amount, not the card.** The sandbox accepts only its
> own whitelist of card numbers, and all of them approve; the `4000…` decline family
> answers `Bad Request`. An amount below 1.00 (`"amount": 99`) declines. There is no
> per-reason decline card. See [Testing](./15-testing.md), "Test cards".

> The contract is a single `card_declined` `code`; the issuer's reason travels in
> `decline_code`. Earlier revisions listed `expired_card`, `incorrect_cvc` and
> `processing_error` as top-level codes. They are not the contract. Branch on
> `code` **and** `decline_code`: a 2026-08-31 audit still saw `do_not_honor` in
> `code`. `incorrect_cvc` / `expired_card` PANs are **not simulated** in the
> [Testing](./15-testing.md) catalog.

```json
{
  "error": {
    "type": "card_error",
    "code": "card_declined",
    "decline_code": "do_not_honor",
    "message": "The charge was declined."
  }
}
```

---

## Gateway refusals

Sometimes the payment gateway refuses the request itself, before it ever reaches the card
network, for example because an identical payment just went through. That is still a
`402` `card_error` with `code: "card_declined"`, returned at once (the charge does not sit
`pending`; `duplicate_transaction` has one exception, below), and `decline_code` is one of:

| `decline_code` | Meaning | Retry? |
|----------------|---------|:---:|
| `duplicate_transaction` | The gateway already holds an identical recent payment (same card, same amount). The buyer may already have paid. | **No.** Look the payment up first |
| `invalid_merchant` | The gateway does not accept this account's request (credentials or account setup). | No, contact support |
| `invalid_transaction` | The gateway does not allow this transaction. | No, contact support |
| `processing_error` | Any other gateway refusal. Nothing was charged. | Yes, after a short delay, with a new `Idempotency-Key` |
| `merchant_unavailable` | Payment intents and hosted checkout: the account cannot take payments right now (not activated, on hold, or its gateway setup is unusable). Nothing was charged. | No, contact support |
| `fraudulent` | Payment intents and hosted checkout: our fraud screening (the pre-screen or the scored screen) refused the payment. Nothing was charged. See [Fraud blocks](#fraud-blocks-fraud_blocked). | No |

### Payment Workflow rules

A route in your Payment Workflow can refuse a payment before it reaches the gateway: a route
that requires a CVV match, an AVS or postal-code match, a minimum or maximum amount, a currency,
or that blocks anonymous IPs (proxy / VPN). That is the same `402` `card_error`,
`code: "card_declined"`, on every surface (`POST /v1/charges`, payment intents, hosted
checkout), with one `decline_code` and a `message` the buyer can act on:

| `decline_code` | Meaning | Retry? |
|----------------|---------|:---:|
| `incorrect_cvc` | The card's security code did not match. | Yes, after the buyer corrects it |
| `incorrect_zip` | The card's postal code did not match. | Yes, after the buyer corrects it |
| `incorrect_address` | The card's billing address did not match. | Yes, after the buyer corrects it |
| `amount_too_large` / `amount_too_small` | The amount is outside the route's limits. | No, change the amount or the route |
| `currency_not_supported` | The route does not accept this currency. | No |
| `fraudulent` | The route blocks anonymous IPs and this one looked anonymized. | No |

Nothing was charged. When several rules fail, the code names one: an anonymous IP first, then
the field the buyer can fix, then a limit. Retry with a **new** `Idempotency-Key`: the same key
replays the same refusal. A payment intent stays usable, so the buyer corrects the field and pays
again on it; the Payment Element shows the matching sentence.

`message` carries the gateway's own text, including its reference:

```json
{
  "error": {
    "type": "card_error",
    "code": "card_declined",
    "decline_code": "duplicate_transaction",
    "message": "Duplicate transaction REFID:3154897712"
  }
}
```

**Do not retry `duplicate_transaction`.** Check [`GET /charges`](./03-charges.md) (or your
`charge.succeeded` webhook) for the payment it duplicates before you charge again. Retrying
under a new key is how a buyer is charged twice. In test mode the sandbox treats the same
card and amount as a duplicate for about 20 minutes: vary the amount between test runs.

Before it answers `duplicate_transaction`, WorthyPayments asks the gateway once whether it
already holds this very charge (a copy of your own request that reached it first). If it
does, you get that payment (`succeeded`, or `requires_capture` for a manual capture)
instead of the refusal. If the gateway cannot say yet, the charge is `pending`: retry with
the same `Idempotency-Key` (see [Idempotency](./12-idempotency.md#a-pending-charge-is-not-a-final-answer)).

The same decline reads differently on the charge object: its `outcome.network_status` is
`not_sent_to_network` (an issuer decline reads `declined_by_network`). `processing_error` is
also an issuer answer, so tell the two apart by `outcome.network_status`, not by the code. See
[Charges](./03-charges.md).

A gateway that cannot be reached, or does not answer, is not a refusal: that stays a
`502` `api_error` with generic wording, or a `pending` charge whose outcome is still being
established. See [Idempotency](./12-idempotency.md).

---

## Fraud blocks (`FRAUD_BLOCKED`)

Our fraud screening can refuse a charge **before** the issuer sees it: first the fraud
pre-screen (velocity rules), then the scored fraud screen. On `POST /v1/charges` either one
is HTTP **400**, `type: invalid_request_error`, not a 402 card decline. Through a payment
intent (the Payment Element) or the hosted checkout it is a decline instead, with
`decline_code: "fraudulent"`, and the buyer reads only "declined".

The Connect reason is `FRAUD_BLOCKED`. The public `code` is `failed_precondition`. A
pre-screen block names its rule in `message`. A scored block's `message` is `FRAUD_BLOCKED:
decision=BLOCK provider=…` and never names a rule or reason code.

### A scored block is a recorded failed charge

When the scored fraud screen refuses a charge, the charge is recorded as a failed charge
and the 400 names it in **`charge`**:

```json
{
  "error": {
    "type": "invalid_request_error",
    "code": "failed_precondition",
    "message": "FRAUD_BLOCKED: decision=BLOCK provider=internal",
    "charge": "TXN-01JABCDEFGHJKMNPQRSTVWXYZ0"
  }
}
```

- **Read it back** with `GET /v1/charges/{charge}`: `status: failed`, `failure_code:
  fraudulent`, `failure_message: "Your card was declined."` and `outcome` `{
  "network_status": "not_sent_to_network", "type": "blocked", "seller_message": "This
  payment was blocked by fraud screening.", "risk_level": "highest" }`. `GET /v1/charges`
  lists it with the same values. The fraud score and decision are on `GET
  /v1/charges/{id}/risk`, never on the error.
- **Webhooks.** `charge.failed` and `transaction.failed` fire once for it, with
  `decline.declineOrigin: "PLATFORM"`. See [Webhooks](./16-webhooks.md#fraud-blocks). They
  fire for a scored block from a payment intent, the hosted checkout or a subscription
  charge too; those flows still answer with their own decline and do not name the charge.
- **Retries.** The 400 is stored under your `Idempotency-Key`. A retry with the same key
  returns the same body, the same `charge` included, and records nothing new. See
  [Idempotency](./12-idempotency.md#what-gets-replayed).
- **No 3-D Secure first.** The block is decided before any 3-D Secure step-up, so a charge
  the screen blocks is refused at once, never first sent through a challenge.
- **A block can still follow a challenge.** The charge a completed challenge resumes is
  screened again, so velocity, a re-score or a block added while the buyer was in the
  challenge can refuse it. Through a payment intent or the hosted checkout it is the same
  `fraudulent` decline as on the first submit.

**A 400 without `charge`** is otherwise identical, and nothing was charged. It is one of:

- a fraud pre-screen block (below), which never carries one;
- a rare scored block for which the payments service returned no charge id. Its record
  may not have been written, or it was written and only the answer naming it was lost. In
  that second case the failed charge exists, and its `charge.failed` / `transaction.failed`
  webhooks fire for a `TXN-…` the 400 never named.

Either way the 400 is stored under your `Idempotency-Key` and replayed exactly for 24
hours, without `charge`.

### Pre-screen velocity

Live-verified test-mode rule: about **5 distinct emails / same PAN / 1440 minutes**.
The 6th distinct email returns:

```json
{
  "error": {
    "type": "invalid_request_error",
    "code": "failed_precondition",
    "message": "FRAUD_BLOCKED: pre-screen velocity_emails_per_card"
  }
}
```

Reuse `audit+0005@example.com` (and Amex `341111111111111`) to stay under the cap. See
[Testing, velocity](./15-testing.md#velocity-fraud_blocked). Do not treat this as
“banned/spam cards are always blocked” in the Payment Element. That copy is not a verified
contract.

---

## Cloudflare 1010

A request that never reaches the API (typically a **missing or empty** `User-Agent`) is
answered by Cloudflare as **1010**. That is an HTML interstitial, not `{ "error": { … }
}`. There is no `code` to branch on. Curl's default UA and a Firefox UA both pass; Chrome
is not required. An empty UA dies.

```bash
curl https://api.worthypayments.com/v1/ping \
  -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"
```

The examples still send `-A` so a copy-paste cannot lose the header. The browser SDK
already sends a real UA.

---

## Loopback in the URL

A second public-edge HTML 403, unrelated to User-Agent: a query string that
contains `http://localhost` (or `http://127.0.0.1`). Typical probes:

```
GET /v1/element-frameable?origin=http://localhost:3000
GET /element?parent=http://localhost:3000
```

The body is `<h1>403 Forbidden</h1>`, `server: cloudflare`, no JSON `error`.
The API never runs. **Local development is allowed.** List `localhost` on the
publishable key and send the origin as a header, with no query:

```bash
curl https://api.worthypayments.com/v1/element-frameable \
  -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: pk_test_xxx" \
  -H "Origin: http://localhost:3000"
```

`200 {"object":"element_frameable","allowed":true}` means that origin may frame the
element. The hosted SDK already sends `Origin` and does not put a raw loopback URL in the
iframe query. You do not have to encode anything.

A `403` JSON envelope with `code: "domain_not_allowed"` is a different
failure: the request reached the API and the origin is not on the key. Add
the hostname to **Allowed domains**. HTML 403 means the request never got
that far.

See [Embedded SDK (local development](./08-embedded-sdk.md#local-development) and
[Testing) loopback in the URL](./15-testing.md#loopback-in-the-url).

---

## Capture, void and refund errors

A follow-up on an existing charge (`POST /charges/:id/capture`, `/void`, or `POST
/refunds`) can fail for reasons the charge itself already settled. The `code` tells you
whether it is worth retrying; the reason travels in `message`.

| `code` | `message` | Meaning | Retry? |
|--------|-----------|---------|:------:|
| `resource_missing` | `TRANSACTION_NOT_FOUND` | No charge with that id under this key | No |
| `failed_precondition` | `INVALID_STATE_FOR_CAPTURE` | Not an uncaptured authorization | No |
| `failed_precondition` | `INVALID_STATE_FOR_VOID` | Already captured, voided or failed | No |
| `failed_precondition` | `INVALID_STATE_FOR_REFUND` | Never captured, so there is nothing to return | No |
| `failed_precondition` | `REFUND_WINDOW_EXPIRED` | Past the acquirer's refund window | No |
| `failed_precondition` | `TRANSACTION_DISPUTED` | The issuer is already pulling this money back through a chargeback. Resolve the dispute instead | No |
| `failed_precondition` | `ALREADY_FULLY_REFUNDED` | The captured total is already returned | No |
| `invalid_argument` | `REFUND_EXCEEDS_REMAINING` | Refund is larger than what is left | Fix the amount |
| `invalid_argument` | `CAPTURE_EXCEEDS_AUTHORIZED` | Capture is larger than the authorization | Fix the amount |
| `invalid_argument` | `CAPTURE_AMOUNT_INVALID` | Not a positive integer of minor units | Fix the amount |
| `invalid_argument` | `CAPTURE_CURRENCY_MISMATCH` | Currency differs from the authorization's | Fix the currency |
| `aborted` | `CAPTURE_IN_PROGRESS` | Another capture holds this charge | Yes, after it settles |
| `aborted` | `VOID_IN_PROGRESS` | Another void holds this charge | Yes, after it settles |

> **Branch on `code`, not on `message`.** The upper-case string is the internal reason and
> is stable enough to log, but it is not a versioned contract. `failed_precondition` and
> `resource_missing` mean "do not retry this request as sent"; `invalid_argument` means
> "the same call works with a corrected amount"; `aborted` is the only one worth repeating
> unchanged.

Amount errors read as `400`, not `402`: nothing was presented to a card, so there is no
decline to report.

---

## Retry guidance

- **`429` / `5xx` / network errors:** retry with **exponential backoff**, honoring
  `Retry-After` on a 429. These are transient. See [Rate limits](./14-rate-limits.md).
- **`processing_error`:** nothing was charged. Retry after a short delay with a **new**
  `Idempotency-Key`. The same key replays this decline.
- **`duplicate_transaction`:** **do not retry.** The gateway already holds an identical
  recent payment. Check [`GET /charges`](./03-charges.md) before charging again.
- **`invalid_merchant` / `invalid_transaction`:** do not retry unchanged. Contact support with
  the `REFID` from `message`.
- **Hard declines** (`pickup_card`, `fraudulent`): **do not retry** the same card; ask the
  shopper for a different payment method.
- **Always send a stable [`Idempotency-Key`](./12-idempotency.md)** on charges and refunds
  (same key for the same logical payment, not a new UUID every click). A charge that
  returns a network error *may* still have been processed. Retrying with the same key
  replays the original result instead of charging twice. A `pending` charge is the one
  answer that is not replayed: retry it with the same key to learn the outcome. If in doubt,
  reconcile via [`GET /charges`](./03-charges.md) or your webhook.
- **`invalid_request_error`** (`api_key_required` / `api_key_invalid` / `permission_denied` /
  `parameter_invalid`): never retry blindly; fix the request, key, or scope first.
