# Errors

## Error format

All handler errors return JSON with a single `error` field:

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

> [!CAUTION]
> **Auth middleware errors are plain text, not JSON.** The `SellerAccountAuthMiddleware` uses `http.Error()` which returns a `text/plain` body. If your client parses every response as JSON, handle the 401 case separately. Example plain-text bodies: `authorization token is required` (401, header absent/malformed), `invalid authorization token` (401, token not recognised), `seller account is not active` (403, account inactive).
>
> Once a request passes the middleware and reaches a handler, all subsequent error responses are JSON.

---

## 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 |
| `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://api.fynex.ai/payments-api/v2/docs#tag/idempotency). |
| `invalid status transition from <status> to capture` | Payment is not in `authorized` or `provider_completed` — check status first |
| `manual settlement required` | Payment was created with `autoSettlement: true` — cannot capture manually |
| `payment is already refunded` | Nothing left to refund |
| `capture is not allowed while a refund is in progress` | Wait for the pending refund to finish before retrying any capture decision |
| `capture is not allowed after a successful refund` | Do not capture again after any successful refund on the payment |
| `refund is already in progress` | A refund is in `refund_pending`. Retry the original `POST /refund` with the same `Idempotency-Key` to replay that row; use a different key only after it reaches `succeeded`, `failed`, or `cancelled`. |
| `refund is allowed only for provider_completed/settled/deposit_confirmed/refund_failed/refund_cancelled payments` | Payment is not in a refundable captured state and is not a retryable failed/cancelled refund |
| `refund amount exceeds remaining refundable amount` | The requested refund exceeds the remaining captured balance after successful prior refunds |
| `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` |

---

## Rate limiting (429 responses)

Every endpoint under `/payments-api/v1/*` is rate-limited per seller account using a token-bucket keyed on the authenticated `seller_account_id`. **The budget differs between staging and production**, so do not hard-code a rate: read `X-RateLimit-Limit` and `X-RateLimit-Remaining` from the response, which are authoritative for the environment you are calling. The budget is per-seller (one noisy client cannot starve others).

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

| Header | Meaning |
|--------|---------|
| `X-RateLimit-Limit` | The bucket size for this seller (e.g. `30`) |
| `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=30;qu="requests";w=60` — 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: 1
X-RateLimit-Limit: 30
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 12
RateLimit-Policy: "seller";q=30;qu="requests";w=60
RateLimit: "seller";r=0;t=12

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

**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 — the budget differs by environment, so an interval that is comfortable in one may exhaust the other. 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. If the headers are absent on an authorized response, 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://api.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://api.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://api.fynex.ai/payments-api/v2/docs#tag/3ds) and [Troubleshooting](https://api.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",
  "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.

Note that the API does not currently distinguish either decline from an ordinary
`2001`: there is no dedicated `failureCode` for an AVS or CVV mismatch, and
`avsResult` / `cvvResult` are not published on the payment. The advice above
therefore applies 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://api.fynex.ai/payments-api/v2/docs#tag/3ds).

---

## The `/checkout` Idempotency-Key pitfall

`POST /checkout` validates the `Idempotency-Key` header **before** 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) — not a 401. 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://api.fynex.ai/payments-api/v2/docs#tag/polling-sse)** — Verify payment and payout state by polling or server-sent events.
- **[Idempotency & retries](https://api.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. |
| `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. |
| `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 has not been ingested. The capture succeeded, but the settlement the refund depends on has not been ingested yet. The payment is still refundable. | `transient` | safe | Retry later. This clears on its own once the settlement arrives, typically within a day; 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. |
| `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.
