# Errors

## Error format

Every error — every `4xx` and `5xx` — is JSON with an `error` and a `code`:

```json
{ "error": "amount must be greater than 0", "code": "bad_request" }
```

`code` is the contract; `error` is prose for a human and may be reworded in any
release. Where the API can say more than the status, `code` is specific — see
*Authentication errors* and *Common 422 responses* below, `idempotency_key_reused`,
`rate_limited`. Otherwise it is derived from the status:

| Status | `code` |
|--------|--------|
| `400` | `bad_request` |
| `401` | `unauthorized` |
| `403` | `forbidden` |
| `404` | `not_found` |
| `405` | `method_not_allowed` |
| `409` | `conflict` |
| `410` | `gone` |
| `413` | `payload_too_large` |
| `415` | `unsupported_media_type` |
| `422` | `unprocessable_entity` |
| `429` | `rate_limited` |
| `500` | `internal_error` |
| `502` | `bad_gateway` |
| `503` | `service_unavailable` |
| `504` | `gateway_timeout` |

Any other `4xx` is `client_error` and any other `5xx` is `server_error`. A
response may gain a more specific `code` in a later release, so branch on the
codes you know and handle any other `code` by its HTTP status.

### Authentication errors

Authentication failures use the same JSON envelope, and always carry a `code`:

| Status | `code` | `error` | Meaning |
|--------|--------|---------|---------|
| `401` | `missing_api_key` | `authorization token is required` | The `Authorization` header is absent, or has no token after `Bearer ` |
| `401` | `invalid_api_key` | `invalid authorization token` | The token is not recognised — revoked, mistyped, or issued by the other environment's host |
| `401` | `key_environment_mismatch` | `invalid authorization token: this key is for the account's other mode …` | A real key for the other mode — typically an `sk_test_` key after the account went Live. Use the `sk_live_` key on a Live account, `sk_test_` on a Demo one |
| `401` | `sandbox_expired` | `invalid authorization token: this sandbox has expired …` | A self-serve sandbox key after its sandbox expired. Create a new sandbox |
| `401` | `api_key_expired` | `invalid authorization token: this API key has expired …` | An `sk_` key used after its own `expiresAt`. Create or regenerate a key |
| `401` | `invalid_token` | `invalid authorization token: invalid access token: …` | An OAuth access token of the MCP endpoint that expired (refresh it), whose connection was revoked, or that was sent to the REST API |
| `403` | `account_not_active` | `seller account is not active` | The token is valid but the seller account is not active |
| `403` | `insufficient_scope` | `forbidden`, or `this API key is restricted: …` | A delegated dashboard session whose user lacks the permission the route needs, or a restricted API key without the permission; the message names what is missing |
| `500` | `auth_unavailable` | varies | The authentication layer itself failed; retry with backoff |

```json
{ "error": "invalid authorization token", "code": "invalid_api_key" }
```

Every `401` also carries `WWW-Authenticate: Bearer`.

A request no route serves answers `404` with `code` `not_found`, and a known path called with the wrong method answers `405` with `code` `method_not_allowed`. Operations live under `/payments-api/v1`; a request to an operation under `/payments-api/v2` — the documentation prefix — says so in its `error`.

## Machine-walkable support path

The currently supported error-to-support path is described by this stable
object. It is a documentation contract, not an assertion that every runtime
error response already contains a `docsUrl` field (that response change is
reserved for the PAY-16 contract).

```json
{
  "supportPath": {
    "errorGuide": "/payments-api/v2/docs/errors.md",
    "agentIndex": "/payments-api/v2/llms.txt",
    "escalation": {
      "kind": "email",
      "address": "support@fynex.ai",
      "required": ["requestId", "httpStatus", "responseBody", "occurredAt"]
    }
  }
}
```

An automated client should fetch `errorGuide`, then use `agentIndex` to find
the relevant skill or guide. Until a structured escalation endpoint is
published, send the listed fields to `support@fynex.ai`; `requestId` is the
value of the `X-Request-Id` response header. Do not put bearer tokens,
cardholder data, or secrets in `responseBody`. The email route is the current
supported escalation mechanism; it is not a promise that an API endpoint
exists.

---

## Status codes

| Code | Meaning | Retry safely? |
|------|---------|---------------|
| `200` / `201` / `202` | Success | n/a |
| `400` | Validation error in your request | No — fix the input |
| `401` | Missing or invalid bearer token | No — fix auth |
| `403` | Token valid but resource belongs to another seller | No — use the right resource |
| `404` | Resource not found for this seller | No — check the ID |
| `409` | Conflict — current state forbids the action | No — read the body and decide |
| `422` | The card was refused and no funds moved (`initialize-payment`) | No — ask for a different card, then send a new `Idempotency-Key` |
| `429` | Per-seller rate limit exceeded | Yes — honor `Retry-After`, then retry |
| `500` | Internal error | Yes — backoff + retry; alert on persistence |
| `502` | Upstream provider failure | Yes — backoff + retry |
| `503` | Service temporarily unavailable | Yes — backoff + retry |

---

## Common 400 responses

| Message | Likely cause |
|---------|--------------|
| `invalid request body` | JSON parse failed — check `Content-Type` and body syntax. On `/initialize-payment`, a common cause is sending `returnLinks` as an object (`{"success": ..., "failure": ...}` — that shape belongs to `/checkout`) instead of an array of `{rel, href, method}`. |
| `Idempotency-Key header is required` | POST endpoint called without the header |
| `Idempotency-Key must be a valid UUID` | Header value is not a UUID |
| `currencyCode is required` | Missing required field |
| `paymentMethod wero is not supported for currency EUR and country IT` | The APM does not support the requested currency/country. Wero supports EUR in `BE`, `DE`, and `FR` only. The request is rejected before a payment is persisted or routed. |
| `amount must be greater than 0` | Zero or negative `amount` |
| `valid returnLinks are required` | On `/initialize-payment`, the request had no `returnLinks` (or invalid ones) **and** the seller account also has no valid return links. Pass `returnLinks: [{rel, href, method}]` in the request body, or configure default links on the seller account in the Dashboard. |
| `payment configuration is not set for seller account` | Seller has no payment methods configured |

---

## Common 409 responses

These mean the resource's current state forbids the action. Read the message before deciding whether to retry:

| Message | What to do |
|---------|------------|
| `Idempotency-Key reused with a different <field>: original=X, request=Y` | On `/initialize-payment` you re-sent a known `Idempotency-Key` with a body that differs in one of the financial fields (`amount`, `currencyCode`, `countryCode`, `externalOrderRef`, `paymentType`, `paymentMethod`). To replay the original payment, send the original body unchanged. To create a new payment, mint a fresh `Idempotency-Key`. See [Idempotency & retries](https://docs.fynex.ai/payments-api/v2/docs#tag/idempotency). |
| `payment action is not allowed for the current payment status` | A capture or refund the payment's current state does not allow: it is not `authorized` and manual-capture (capture), not refundable yet or any more (refund), a refund is still `pending`, a refund already succeeded (capture), or the amount exceeds what is left to refund. `GET /payments/{payment_id}` shows which — `lifecycleStatus`, `settlementStatus` — and the conditions are listed under *What you can do next* in [Payment Lifecycle](https://docs.fynex.ai/payments-api/v2/docs#tag/payment-lifecycle). |
| `settlement not yet available for refund` | The payment is captured, but the settlement a card refund must target is not ready: the acquirer refuses refunds until its settlement batch has run (a refund on the day of capture gets this), or the batch has not reached Fynex yet. Nothing was refunded and the payment stays refundable. Retry after the next batch, usually the next day, with a **fresh** `Idempotency-Key`; the refused attempt is listed by `GET /payments/{payment_id}/refunds` with `failureCode` `2004`. |
| `payout with this idempotency key already exists` | Replay of a successful create — fetch the existing payout instead |
| `insufficient balance` | Top up the wallet, then retry with a fresh `idempotencyKey` |

---

## Common 422 responses

`POST /initialize-payment` answers `422` when the card itself is refused before
authorization; no funds moved, and re-sending the same card fails identically —
so, unlike a `502`, this is not a retry signal. Branch on `code`:

| `code` | failureCode | Meaning | What to do |
|--------|-------------|---------|------------|
| `card_expired` | `1007` | The card's expiry date has already passed. | Ask the customer for a different card, or for the updated details if their card was reissued. For a stored card, collect a new one. |
| `card_brand_not_supported` | `1008` | The seller's acquiring agreement does not accept this card's brand (for example American Express where only Visa and Mastercard are enabled). | Ask the customer for a card of an accepted brand. If you believe the brand should be accepted, contact Fynex support. |

```json
{ "error": "This card type is not accepted for this payment. Please use a different card.", "code": "card_brand_not_supported" }
```

The payment is still created, in `failed` with the matching `failureCode`, so
`GET /payments-api/v1/payments/{externalOrderRef}` shows it. Replaying the same
`Idempotency-Key` with the same body returns the same `422` and `code`; to try
another card, send a new `Idempotency-Key`. A replay reflects the payment's
current failure, so it answers from the stored payment rather than repeating
the first response verbatim. Treat an unrecognised `code` as a refusal you
cannot fix automatically — new codes may be added. A generic HTTP client or SDK
that treats `4xx` as non-retryable is doing the right thing here.

### A checkout session or payment link no terminal can take

`POST /checkout` and `POST /api/v1/payment-links` answer `422` with `code`
`currency_not_supported` when none of the account's payment terminals can take
the payment — most often a currency the account has no terminal for (a `GBP`
session on an account with only `EUR` terminals), or, for a declared
`countryCode`, a country none of them serves. Nothing is created: no session,
no payment, no link.

```json
{ "error": "currency GBP is not supported for country GB on this account", "code": "currency_not_supported" }
```

The check runs at creation so the customer never reaches a checkout that would
fail with `1003` (`routing`) after they press pay. A session is accepted when
any method the hosted checkout can offer for it would route: the card form, or
a local payment method the account has a terminal for in that currency and
country. A payment link lives longer than a session, so it is refused only when
the account has no terminal that matches the link's currency, mode (live or
test) and payment type and country; a matching terminal that is temporarily
suspended, or not effective yet, does not block the link.

The same request succeeds once the account has a terminal for that currency
(and country), for example after the currency is activated on the account —
ask Fynex support to enable it. Until then, re-sending it gives the same
`422`. Replaying an `Idempotency-Key` that already created a session still
returns that session. If Fynex cannot check the terminals at that moment (a
transient internal error), the session or link is created rather than refused.

---

## Rate limiting (429 responses)

When the per-seller limiter is enabled and available, authenticated business
operation endpoints under `/payments-api/v1/*` are rate-limited using a
token-bucket keyed on the authenticated `seller_account_id`. The standard
budget is **100 requests per hour per seller**. An account or environment with
a provisioned exception can have a different budget, so read
`X-RateLimit-Limit` and `X-RateLimit-Remaining` from the response: they are
authoritative for the environment you are calling. The budget is per-seller
(one noisy client cannot starve others).

A **Demo** account has its own, larger budget: **200 requests per minute per
seller**, counted separately from the Live budget. Test traffic — including a
coding agent that lists, polls and retries in a loop — does not run into the
Live limit, and switching an account to Live does not carry Demo usage over.

Every authorized response — both 200 and 429 — normally carries:

| Header | Meaning |
|--------|---------|
| `X-RateLimit-Limit` | The bucket size for this seller (normally `100`) |
| `X-RateLimit-Remaining` | Tokens left in the bucket after this request |
| `X-RateLimit-Reset` | Whole seconds until the bucket has refilled to `X-RateLimit-Limit` |
| `RateLimit-Policy` | The budget itself, in IETF structured-field syntax: `"seller";q=100;qu="requests";w=3600` — quota, unit, window in seconds |
| `RateLimit` | Where you stand against it: `"seller";r=12;t=45` — remaining, and seconds until reset |
| `Retry-After` *(429 only)* | Whole seconds to wait before the next request is guaranteed to succeed |

> [!NOTE]
> `RateLimit` and `RateLimit-Policy` are the current standards-track fields
> (`draft-ietf-httpapi-ratelimit-headers`). They are **not** the
> `RateLimit-Limit` / `RateLimit-Remaining` / `RateLimit-Reset` triple you may
> remember — that spelling is from an earlier revision of the same draft and
> Fynex does not send it. If your client only knows the old shape, read the
> `X-RateLimit-*` headers, which carry the same numbers.

`X-RateLimit-Reset` is what lets you pace a loop *before* you are refused:
`X-RateLimit-Remaining` alone tells you how many requests are left but not how
long you have to spend them over, and `Retry-After` arrives only once you have
already been throttled.

When the bucket is empty:

```http
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 36
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 3600
RateLimit-Policy: "seller";q=100;qu="requests";w=3600
RateLimit: "seller";r=0;t=3600

{"error":"rate limit exceeded; retry after the Retry-After header value","code":"rate_limited"}
```

Every `429` carries `Retry-After`, and a limiter's `429` carries `code`
`rate_limited`. If the limiter itself is unavailable on a route that fails closed,
the answer is `503` with `Retry-After: 1` and `code` `temporarily_unavailable`.

**How to react:** sleep for at least `Retry-After` seconds, then retry the **same request** with the **same `Idempotency-Key`**. Idempotency-Key replay is safe; the original response is returned once the bucket has capacity.

**How to avoid it:** read `X-RateLimit-Remaining` and `X-RateLimit-Reset` on every response and back off proactively when the remaining count approaches zero. Drive your polling interval from those values rather than from a fixed number of seconds — an account-specific budget can differ from the standard one. Add ±20% jitter to any loop.

**Missing headers:** authorized responses — both `2xx` and `429` — normally
carry the `X-RateLimit-*` headers. `401`/`403` responses never do, because the
budget is keyed on the authenticated seller and the limit is applied after
authentication. A deployment may disable the limiter, and a temporary limiter
outage can allow an authorized request without rate-limit headers. If the
headers are absent, hold your most conservative polling interval rather than
reading it as headroom, and contact support if it persists.

---

## 502 vs 500

- **`502 Bad Gateway`** — Fynex called an upstream processor and it returned a non-OK response or timed out. The action may or may not have been processed upstream — your idempotency key protects you on retry.
- **`500 Internal Server Error`** — Fynex itself encountered an unexpected error. Should be rare. If you see persistent 500s, contact support with the request details.

### Common 502 causes

A 502 can come from either Fynex-side routing (no terminal selected) or an upstream processor returning a non-OK response. An unsupported APM market is instead rejected with `400` before routing. Upstream-side 502s all share the same body shape:

```json
{ "error": "upstream card processor returned <status>" }
```

To distinguish *why* the upstream rejected the request, fetch the payment afterwards (`GET /payments-api/v1/payments/{externalOrderRef}`) and inspect its `failureCode` + `failureStage` fields.

| Body (abridged) | failureCode | Cause | Fix |
|------|-------------|-------|-----|
| `no active terminal found for seller account` | `1003` (`routing`) | The method/currency/country combination is supported, but Fynex routing couldn't find an active seller terminal that matches the request's method, `operationalMode` (Demo vs Live), `countryCode`, and `currencyCode`. | Check the seller's attached terminals in the Dashboard and confirm at least one active link supports the APM and matches the request's `countryCode` + `currencyCode` under the seller's `operationalMode`. See [Troubleshooting](https://docs.fynex.ai/payments-api/v2/docs#tag/troubleshooting). |
| `upstream card processor returned 400` | `2002` (`authorization`) — `billingDetails` missing | Card payment sent without billing country and/or zip. The Fynex DTO marks `billingDetails` as optional but the upstream card processor requires both. | Always populate `billingDetails.country` (or `countryCode`) and `billingDetails.zip` (or `postalCode`) on card initialize requests. See [Server-to-Server](https://docs.fynex.ai/payments-api/v2/docs#tag/server-to-server). |
| `upstream card processor returned 400` | `2002` (`authorization`) — payment handle in wrong state | `/finalize-payment` was called before the customer completed the 3DS challenge at `actionUrl`. The upstream payment handle is still in its initial state and cannot authorize a payment. | Visit `actionUrl` from the initialize response, complete the challenge, then call `/finalize-payment`. See [3DS Authentication](https://docs.fynex.ai/payments-api/v2/docs#tag/3ds) and [Troubleshooting](https://docs.fynex.ai/payments-api/v2/docs#tag/troubleshooting). |
| `upstream card processor returned 409` | `2002` (`authorization`) — duplicate merchant reference | Re-using the same `externalOrderRef` on a fresh idempotency key. The upstream processor deduplicates on its own merchant reference, independent of the Fynex `Idempotency-Key`. | Either re-use the original `Idempotency-Key` (replays the original response) or send a fresh `externalOrderRef`. |
| `upstream card processor returned <other>` | `2001` / `2002` / `2003` | Generic upstream processor failure, decline, or timeout. | Backoff and retry with the same `Idempotency-Key`. Persistent failures: contact Fynex support with the payment's `externalOrderRef` so we can correlate against upstream logs. |

---

## Failure codes inside payment responses

Even when the HTTP call returns `200`, the payment itself may have failed at the processor. Inspect the response body:

```json
{
  "paymentId": "ORDER-1042",
  "status": "failed",
  "lifecycleStatus": "failed",
  "legacyStatus": "failed",
  "failureCode": 2001,
  "retry": "never",
  "failureCategory": "hard",
  "failureDescription": "Provider declined the transaction"
}
```

`failureCode` is the contract; `failureDescription` is prose for a human and may
be reworded in any release, so branch on the code and show the description.
Every code is enumerated below with its cause, whether retrying can succeed, and
what to do — and the same table is on the field itself in `openapi.json`, so a
generated client carries it. The retry verdict also rides on the response as
`retry` (`safe`, `fix_first` or `never`), on every payment and refund response
and on the `PaymentCompleted` webhook, so you can branch on it without joining
the table yourself.

The retry column is the part worth reading twice. Getting it wrong costs money in
both directions: retrying a decline the issuer already made loses the sale and can
get the card blocked, while *not* retrying a timeout with the **original**
`Idempotency-Key` is how a customer gets charged twice.

`failureCategory` rides beside it and answers the other question — not *what to
do* but *what happened*: `hard`, `transient`, `requires_change`,
`integration_error`, `cancelled`, or `unknown` for a code this catalogue does
not classify. Branch your retry loop on `retry`; count, chart and alert on
`failureCategory`. The split is what keeps a spike of `integration_error` (your
bug) out of the same number as a spike of `hard` (your conversion rate). Both
fields are absent while the payment has not failed.

`failureStage` narrows it further — the stage says *where* the payment stopped,
the code says *why*. Anything before `authorization` never reached a provider, so
no money moved.

### Two card declines that leave a hold

A declined payment is not always a released card. On an **AVS mismatch** — the
billing address or postal code did not match the issuer's record — the issuer
may have placed a temporary authorization hold that the cardholder sees on their
statement for up to 3–5 business days even though the payment failed and Fynex
captured nothing. The same is true on a **CVV mismatch**: the security code was
wrong, the payment failed, and the cardholder may still see a pending
authorization for up to 3–5 business days before their issuer drops it.

Neither hold is something Fynex can release, and neither is a charge. Say so in
your own customer-facing copy before the buyer calls their bank — and do not
re-run the same card repeatedly to "clear" it, because each attempt can add
another hold.

An AVS refusal has its own code: `failureCode: 2005` (address verification
failed, `failureCategory: requires_change`) — the buyer must correct the billing
address, not retry. A CVV mismatch is still reported as an ordinary `2001`, and
`avsResult` / `cvvResult` are not published on the payment, so for CVV the
advice above applies only when you already know the decline reason from your
own checkout flow.

### Soft declines and 3-D Secure

`3002` is the one decline that is not a refusal of the card: the issuer
soft-declined the authorization and asked for strong customer authentication
instead. The card processor reports its own soft-decline code at the
authorization gate and Fynex maps it onto `3002`, so branch on `3002` rather
than on any processor-specific number. It carries `retry: fix_first` and `failureCategory: requires_change`,
not `hard` — re-run the payment through a 3-D Secure flow and it can succeed.
Retrying without one fails identically, and counting it as a decline throws away
a sale you can still make. See [3DS Authentication](https://docs.fynex.ai/payments-api/v2/docs#tag/3ds).

`3003` is the customer walking away rather than the issuer asking: the customer
was sent to 3-D Secure and did not complete it before the authentication session
expired, so no authorization was ever attempted and nothing was charged. It also
carries `retry: fix_first` and `failureCategory: requires_change`. Replaying the
request with the same `Idempotency-Key` returns the same failed payment — the
session it depended on is gone — so ask the customer to pay again and complete
the verification step their bank shows them.

---

## The `/checkout` Idempotency-Key pitfall

`POST /checkout` validates the `Idempotency-Key` header right after auth, in `initFinalizeHeaderMiddleware`. A missing or malformed key returns a JSON `400` — `{"error":"Idempotency-Key header is required"}` (header absent or empty) or `{"error":"Idempotency-Key must be a valid UUID"}` (value is not a UUID). A bad token still answers `401` first. If you see one of these 400s from `/checkout`, fix the `Idempotency-Key` header rather than your token.

---

## Recovering from partial failures

If `/initialize-payment` returns `502` but you don't know whether the processor created or charged the payment:

1. Wait 30–60 seconds.
2. Retry the unchanged request with the **same idempotency key**. Fynex and its APM provider integration reuse the same provider-attempt idempotency key and byte-stable create body, so a timed-out APM create is reconciled instead of creating a second charge. If the charge is still awaiting buyer action, the replay returns the current redirect or Multibanco payment instructions again.
3. If the retry also fails, poll the canonical state. From a server-to-server backend the simplest path is `GET /payments-api/v1/payments/{externalOrderRef}` (bearer auth, same token). From cookie-session contexts use the GraphQL `genericPayment(id)` query instead.

The same pattern applies to `/finalize-payment`, `/payments/{id}/capture`, `/payments/{id}/refund`, and `/payouts`.

## See also

- **[Polling & SSE](https://docs.fynex.ai/payments-api/v2/docs#tag/polling-sse)** — Verify payment and payout state by polling or server-sent events.
- **[Idempotency & retries](https://docs.fynex.ai/payments-api/v2/docs#tag/idempotency)** — Make your retries safe with idempotency keys.


---

## Payment failure codes

Returned as `failureCode` on every payment and refund response, and on the
`payment.completed` webhook. A `200` does not mean the payment succeeded —
read `status`, and when it is `failed` read this.

Why the payment failed. `0` means it has not.

**Retry** says what re-sending achieves: **safe** — the same request with the same `Idempotency-Key` can succeed; **fix first** — retrying unchanged fails identically, something has to change; **never** — a decision was made or the outcome is not knowable by re-sending, and an automatic retry is wrong.

**Category** says what KIND of failure it is: `hard` — a decision was made and it stands; `transient` — nothing was decided; `requires_change` — the customer's instrument or authentication has to change; `integration_error` — the request or the seller's configuration is wrong, not the customer's card; `cancelled` — the payment was called off; `unknown` — unclassified. Both ride on the response, as `retry` and `failureCategory`.

| Code | Meaning | Category | Retry | What to do |
|---|---|---|---|---|
| `1001` | Request validation failed. The request was rejected before it reached any provider. The response body names what was wrong. | `integration_error` | fix first | Correct the request and send it with a NEW Idempotency-Key. Replaying the old key returns the same rejection. |
| `1002` | Payment rejected by risk policy. Fynex's own risk policy declined the payment. Distinct from a card decline: the card was never charged. | `hard` | never | Do not retry automatically — the same request produces the same decision. Offer the customer a different payment method, and contact Fynex support if you believe the decline is wrong. |
| `1003` | No active terminal found for seller account. No active terminal on the seller account matches this request's payment method, currency, country and mode, so there was nothing to route to. | `integration_error` | fix first | A configuration problem, not a customer one. Check the seller's terminals in the dashboard and confirm at least one active link covers the request's method, currency and country under the account's current mode. |
| `1004` | Compliance screening declined the transaction. Transaction monitoring returned a decline before authorization. The card was never charged. | `hard` | never | Do not retry. The decision is recorded and a retry produces the same outcome; contact Fynex support to have the case reviewed. |
| `1005` | Compliance screening paused the transaction for review. Transaction monitoring did not return a decision in time, or returned one that requires review. The card was never charged. | `hard` | never | Do not retry automatically. The case is followed up outside the API; contact Fynex support with the payment's `externalOrderRef`. |
| `1006` | Payment initialization was interrupted. Checkout claimed the payment but failed before any provider request was made — for example the buyer disconnected mid-initialization. Not a card decline. | `transient` | safe | Retry with the same Idempotency-Key. Nothing reached a provider, so no charge can be duplicated. |
| `1007` | This card has expired. Please use a different card. The card's printed expiry date had already passed when the charge was attempted. Rejected before any provider was contacted, so no authorization exists and no funds moved. | `requires_change` | fix first | Do not retry this card — an expiry date only moves further into the past, so every retry fails identically. Ask the customer for a different card, or for the updated details if their card was reissued. For a stored card, collect a new one and replace it. |
| `1008` | This card type is not accepted for this payment. Please use a different card. The seller's acquiring agreement does not accept this card's brand (for example American Express where only Visa and Mastercard are enabled). The card was refused before any funds moved. | `requires_change` | fix first | Do not retry this card — every retry fails identically. Ask the customer for a card of an accepted brand. |
| `2001` | Provider declined the transaction. The card issuer or the acquirer declined the authorization. This is the ordinary decline. | `hard` | never | Do not retry the same card automatically — an issuer that declined once declines again, and repeated attempts can get the card blocked. Show the customer `failureDescription` and let them choose to try again or use a different card. |
| `2002` | Provider returned an error. The provider returned an error rather than a decision — a malformed exchange, a rejected field, or an upstream fault. The payment's outcome is not known from this response alone. | `transient` | safe | Back off and retry with the SAME Idempotency-Key, which replays rather than re-charges. If it persists, poll the payment before sending anything new. |
| `2005` | The billing address did not match the card issuer's records. The payment was not taken; any authorization hold is the card issuer's to release. The card issuer refused the authorization because the billing address did not match its records (Address Verification System). The acquirer reserves the amount on every attempt; release is the issuer's, and the delay is commonly several business days. | `requires_change` | fix first | Do not re-send the same address — it fails identically and reserves the amount again, so each blind retry costs the customer another hold. Collect the billing address exactly as the customer's bank holds it, including street number and postcode, then submit a new payment. |
| `2006` | Provider rejected a request field. The provider refused the request itself, naming a field it could not accept — part of the billing address, or the card number. No authorization was attempted, nothing was charged, and no hold was placed. | `requires_change` | fix first | Re-sending the same values fails identically. Collect the billing address and card number again — check that the state/province is the two-letter code for the billing country, and that the card number was entered in full — then submit a new payment. |
| `2003` | Provider request timed out. The provider did not answer in time. The request may or may not have been processed upstream. | `transient` | safe | Wait 30–60 seconds and retry with the SAME Idempotency-Key. Never send a fresh key after a timeout — that is how a customer gets charged twice. |
| `2004` | Refund is not yet available: the provider settlement is not ready yet. The capture succeeded, but the settlement the refund depends on is not ready yet: the acquirer has not batched it (a refund on the day of capture), or the batch has not reached Fynex. The payment is still refundable. | `transient` | safe | Retry after the next settlement batch, usually the next day, with a fresh Idempotency-Key. This clears on its own; it is not a permanent refusal. |
| `3001` | Capture failed. The authorization existed but the capture did not complete. | `transient` | safe | Poll the payment first, then retry with the SAME Idempotency-Key if it is still uncaptured. An authorization also expires — a capture attempted after expiry cannot succeed however often it is retried. |
| `3002` | Soft decline — the issuer requires strong customer authentication (3-D Secure) for this transaction. The issuer soft-declined the authorization and asked for strong customer authentication (3-D Secure) instead. No decision was made against the card. | `requires_change` | fix first | Re-run the payment through a 3-D Secure flow; retrying without it fails identically. |
| `3003` | Card authentication (3-D Secure) was not completed in time. The customer was sent to their card issuer's authentication step (3-D Secure) and did not complete it before the payment session expired — for example they closed the page or did not approve it in their banking app. No authorization was attempted and nothing was charged. | `requires_change` | fix first | Re-sending this payment cannot succeed: the authentication session it depended on has ended. Ask the customer to pay again and complete the verification step their bank shows them. |
| `4001` | Settlement failed. The payment authorized and captured, but settling the funds did not complete. Platform-side. | `hard` | never | Nothing to retry through the API — re-sending cannot move a settlement. Contact Fynex support with the payment's `externalOrderRef`. |
| `5001` | Deposit confirmation timed out. A bank-transfer deposit was not confirmed within the window. The transfer may still arrive. | `hard` | never | Poll the payment rather than re-sending. A second request creates a second expected deposit, and the customer has already sent the money once. |
| `9001` | Cancelled by merchant. You cancelled the payment. | `cancelled` | never | Start a new payment with a new `externalOrderRef` if the customer wants to try again. |
| `9002` | Cancelled by the system. Fynex cancelled the payment — most often an unfinished checkout that reached its expiry. | `cancelled` | never | Start a new payment. The old one is terminal and cannot be revived. |
| `9999` | Unknown failure. The failure did not map to any code above. This is a gap in our classification, not a statement about your request. | `unknown` | never | Poll the payment for its canonical state before doing anything else, and report it to Fynex support with the `externalOrderRef` so the case can be classified. |

## Payout failure codes

Why the payout failed. Absent while it has not.

In every case the held funds are returned to the seller's available balance before the payout is marked failed, so a failed payout never leaves money stranded — and no payout failure is retryable by simply re-sending the same request. Treat a failed payout as terminal, read `failureMessage` for what to tell the seller, and create a new payout only after the underlying cause is addressed.

The enumerated code list is not published yet; it is pending a rename that removes supplier-specific prefixes from four of the values.
