# Troubleshooting & FAQ

This page aggregates the most common integration mistakes and questions across all Fynex guides into one scannable reference. Use your browser's Find (Ctrl+F / Cmd+F) to search for an error message, HTTP status code, or symptom.

> [!NOTE]
> Still stuck after checking here? Email **support@fynex.ai** with your `externalOrderRef` (or payout `id`), the HTTP status and response body, and the approximate time of the request.

---

## 1. Authentication & tokens

### Q: I'm getting `401 Unauthorized` on every request

Check the `Authorization` header format. The correct form is:

```http
Authorization: Bearer <your_token>
```

Common mistakes: omitting the word `Bearer`, adding extra whitespace, wrapping the token in quotes, or using a staging token against the production base URL (`https://api.fynex.ai`) or vice versa. Staging tokens only work against `https://staging-api.fynex.ai`. A quick smoke-test is `GET /payment-methods` — a `200 OK` confirms the token is valid and the seller account is active.

### Q: The 401 response body is plain text, not JSON — my client is crashing trying to parse it

That is expected. Errors thrown by the `SellerAccountAuthMiddleware` use Go's `http.Error()`, which returns `text/plain`. Examples: `seller authorization token is required`, `unauthorized`. Once a request passes authentication and enters a handler, all subsequent error responses are JSON `{"error": "..."}`. Handle the 401 case in your HTTP client before you attempt JSON parsing.

See [Errors](https://api.fynex.ai/payments-api/v2/docs#tag/errors) for the full error-shape reference.

### Q: I get a 401 with the message `"sellerAccountId is missing in auth context"` on `POST /checkout` even though my Bearer token is correct

This message is misleading. It almost always means the **`Idempotency-Key` header is missing or not a valid UUID**, not that authentication failed. The checkout handler reads the seller context via the same helper that validates the idempotency key — a missing or malformed key surfaces as this 401 before the Bearer token check completes. Verify your `Idempotency-Key` header first.

See [Request Headers](https://api.fynex.ai/payments-api/v2/docs#tag/headers) and [Idempotency & retries](https://api.fynex.ai/payments-api/v2/docs#tag/idempotency).

### Q: How do I rotate my token?

Use the GraphQL mutation `regenerateSellerAccountToken` on `/dashboard/graphql` (cookie-session authenticated). Supply your seller account numeric ID:

```graphql
mutation RotateToken($merchantId: ID!) {
  regenerateSellerAccountToken(merchantId: $merchantId) {
    id
    authorizationToken
  }
}
```

> [!CAUTION]
> Rotation **atomically replaces** the existing token. The old token is invalidated immediately — there is no two-token overlap window. Update your secret manager and restart affected services before calling this mutation in production.

See [Authentication & Tokens](https://api.fynex.ai/payments-api/v2/docs#tag/authentication) for the full rotation steps.

### Q: Do tokens expire?

No. Tokens have no expiry by default. The only way to invalidate a token is to rotate it via `regenerateSellerAccountToken`.

---

## 2. Payment creation — `/initialize-payment` and `/checkout`

### Q: I sent `successUrl` and `cancelUrl` but they were silently ignored

Top-level `successUrl`, `cancelUrl`, and `failureUrl` are not part of either endpoint's DTO. The correct field name and **shape depend on which endpoint you're calling**:

- **`POST /checkout`** (hosted checkout) — nest URLs under `returnUrls` as an object:

  ```json
  {
    "returnUrls": {
      "success": "https://example.com/orders/123/success",
      "failure": "https://example.com/orders/123/failure"
    }
  }
  ```

- **`POST /initialize-payment`** (server-to-server) — pass `returnLinks` as an **array** of `{rel, href, method}`:

  ```json
  {
    "returnLinks": [
      { "rel": "on_completed", "href": "https://example.com/orders/123/success", "method": "GET" },
      { "rel": "on_failed",    "href": "https://example.com/orders/123/failure", "method": "GET" },
      { "rel": "default",      "href": "https://example.com/orders/123/return",  "method": "GET" }
    ]
  }
  ```

  Sending `returnLinks` as an object on `/initialize-payment` fails JSON decoding and returns `400 {"error":"invalid request body"}`. Valid `rel` values: `default`, `on_completed`, `on_failed`, `on_cancelled`.

See [Hosted Checkout](https://api.fynex.ai/payments-api/v2/docs#tag/hosted-checkout) and [Server-to-Server](https://api.fynex.ai/payments-api/v2/docs#tag/server-to-server).

### Q: `/initialize-payment` returns `400 {"error":"valid returnLinks are required"}`

The endpoint resolves return links in this order: (1) the `returnLinks` array on the request body, (2) the return links configured on the seller account in the Dashboard. If neither source provides at least one valid link, the request is rejected with this error. Either pass `returnLinks` explicitly in the request body, or configure defaults on the seller account.

### Q: I sent a `metadata` field on `/initialize-payment` but it doesn't appear in the response

`metadata` does not exist on `InitiatePaymentRequest` or its response DTO. Other fields that also do not exist on this DTO: `saveCard`, `customerEmail`, `splitRules` (top-level), `returnUrl` (top-level), `returnUrls` (the `/checkout` shape — `/initialize-payment` uses `returnLinks` instead), `successUrl`, `cancelUrl`. The server silently discards unknown JSON fields.

### Q: `/initialize-payment` rejects my currency code, but `/checkout` accepts it

`POST /initialize-payment` enforces a strict currency allowlist: `EUR`, `USD`, `GBP`, `DKK`, `NOK`, `SEK`. An unrecognized code returns `400`. By contrast, `POST /checkout` does not validate the currency at session-creation time — an unsupported currency may be accepted at that stage and only rejected when the payment is actually processed. Test end-to-end in staging to catch currency issues before going live.

### Q: I'm getting `502 {"error":"upstream card processor returned 409"}` (the payment's `failureCode` is `2002` with an upstream "Duplicate merchant reference" detail) — but I sent a fresh `Idempotency-Key`

The upstream card processor deduplicates independently of Fynex, on its `merchantRefNum` field — which Fynex sends as your `externalOrderRef`. Re-using the same `externalOrderRef` returns `409` from the processor, regardless of your Fynex `Idempotency-Key`. Use a unique `externalOrderRef` per attempt — typically your internal order ID plus an attempt counter, e.g. `ORDER-1042`, `ORDER-1042-r1`, `ORDER-1042-r2`.

The Fynex `Idempotency-Key` (deduplicates Fynex API calls) and the upstream `merchantRefNum` (deduplicates upstream transactions) are **separate** keys — both must be fresh on a genuinely new attempt; both must be reused identically when retrying after a network failure.

See [Idempotency & retries](https://api.fynex.ai/payments-api/v2/docs#tag/idempotency) for the full retry semantics.

### Q: My idempotency key gets a `409` — can I reuse it?

Idempotency keys are per-endpoint and per-seller. Same key + same body = the existing operation is returned (the status code changes from `201`/`202` to `200` on replay). For an active redirect APM, Fynex rehydrates the existing provider redirect in that response.

Same key + different body on `/initialize-payment` returns `409 Conflict` with a concrete reason — for example:

```json
{ "error": "Idempotency-Key reused with a different currencyCode: original=USD, request=EUR" }
```

The check covers `amount`, `currencyCode`, `countryCode`, `externalOrderRef`, `paymentType`, and `paymentMethod`. If you need to retry with corrected parameters on any of those fields, use a fresh UUID. If you are retrying an unchanged request after a network failure, reuse the original key — that is the intended behavior.

See [Idempotency & retries / Reusing a key with a different body](https://api.fynex.ai/payments-api/v2/docs#tag/idempotency).

See [Idempotency & retries](https://api.fynex.ai/payments-api/v2/docs#tag/idempotency).

### Q: Do I need to call `/finalize-payment` even when `requiresAction` is `false`?

Almost always: yes. `/initialize-payment` returns `202 Accepted` for a new payment but does not confirm capture. Call `/finalize-payment` and check the `status` field before fulfilling the order.

The single exception is the `skip3DS: true` server-to-server path. There the upstream payment handle is created already in `PAYABLE` state, and Fynex's upstream status poller auto-finalizes the payment on its next tick (~5 seconds). For those payments you do not need to call `/finalize-payment` yourself — poll `genericPayment(id)` until `status` reaches `provider_completed` or a terminal failure. See [Server-to-Server / Skipping 3DS](https://api.fynex.ai/payments-api/v2/docs#tag/server-to-server).

### Q: I'm getting `502 {"error":"upstream card processor returned 400"}` (the payment's `failureCode` is `2002`) — what's missing?

The upstream card processor requires `billingDetails.country` (or `countryCode`) and `billingDetails.zip` (or `postalCode`). The Fynex DTO marks `billingDetails` as optional and forwards an empty object as empty strings, which the upstream rejects. Add at minimum:

```json
"billingDetails": {
  "country": "GB",
  "zip": "SW1A1AA"
}
```

If you also see `fieldErrors` in the upstream body for `billingDetails.state`, that field is required by the upstream processor for US/CA cards.

### Q: I get `502 {"error":"no active terminal found for seller account"}` even though my seller account is active

The HTTP response carries only the error string. On the payment row, Fynex also stamps `failure_code: 1003` and `failure_stage: routing` — visible to platform operators via the database / dashboard, but **not** returned in the HTTP body.

First distinguish an unsupported method market from missing seller routing. Fynex rejects an unsupported APM combination with `400` before creating a payment — for example, Wero supports `EUR` in `BE`, `DE`, and `FR`, so Wero with `countryCode: "IT"` returns `paymentMethod wero is not supported for currency EUR and country IT`.

If you instead receive `no active terminal found`, the combination is supported but no active terminal matches the **payment method**, **operational mode**, **country**, and **currency**. Check the seller's attached terminals (in the dashboard or via `seller_account_terminals` → `terminals`) and confirm at least one active link supports the requested instrument and matches its `currencyCode`, `countryCode`, and `operationalMode` (Demo vs. Live).

---

## 3. 3DS handling (server-to-server)

### Q: After the 3DS redirect I'm losing my checkout state — the resume page has no payment ID

The 3DS challenge is a full-page browser redirect away from your origin. All in-memory JavaScript state (React state, module-level variables) is destroyed during that navigation. Persist the `paymentId` and any UI state you need to `localStorage` before redirecting, then read it back on your return page:

```js
// Before redirect
localStorage.setItem('checkout_payment_id', paymentId);
localStorage.setItem('checkout_amount', String(amount));

// On return page (your returnUrl)
const paymentId = localStorage.getItem('checkout_payment_id');
await finalizePayment(paymentId);
localStorage.removeItem('checkout_payment_id');
localStorage.removeItem('checkout_amount');
```

The Fynex hosted dashboard uses `checkout_*` keys for this purpose. If you share an origin with the dashboard, use distinct key names to avoid collisions.

See [3DS Authentication](https://api.fynex.ai/payments-api/v2/docs#tag/3ds).

### Q: React strict-mode is firing my finalize call twice and I'm getting a double-capture error

React 18+ strict mode invokes effects twice in development. Guard your `/finalize-payment` call with a `useRef` flag:

```jsx
const hasSubmitted = useRef(false);

useEffect(() => {
  if (hasSubmitted.current) return;
  hasSubmitted.current = true;
  finalizePayment(paymentId);
}, []);
```

See [3DS Authentication](https://api.fynex.ai/payments-api/v2/docs#tag/3ds).

### Q: How do I know whether 3DS will be triggered before I redirect the customer?

You don't know in advance — it depends on the card issuer's risk decision. After calling `POST /initialize-payment`, inspect the response: if `requiresAction` is `true`, the customer must complete the 3DS challenge at `actionUrl`. If `requiresAction` is `false`, the payment may already be in `authorized` or `provider_completed` state and you can proceed directly to `/finalize-payment`.

### Q: Can I use an iframe for the 3DS challenge page?

No. Card issuers reject embedded challenges. The redirect to `actionUrl` must be a full-page browser navigation (`window.location.href = actionUrl`).

### Q: I get `failureCode: 2002` and a 502 `{"error":"upstream card processor returned 400"}` after calling `/finalize-payment`

The upstream response (visible in the platform-side payment exchange logs) carries a field-level
error on `paymentHandle`, reporting that the handle is in a state from which a payment cannot be
taken.

This means `/finalize-payment` was called **before** the customer completed the 3DS challenge at `actionUrl`. The upstream payment handle is still in `INITIATED` state and cannot be used to authorize a payment. Two common ways to hit this:

- A test script that calls `/initialize-payment` and immediately calls `/finalize-payment` without visiting `actionUrl` in a browser. The sandbox 3DS page has a "complete" button — open `actionUrl` first, click through the challenge, then call `/finalize-payment`.
- A frontend that signals the backend to finalize too early — before the issuer redirects the customer back to your `returnUrl`. Always wait for the post-redirect signal (server-rendered return page or an explicit message from your frontend).

If you want to test the no-3DS path, the 3DS challenge is controlled by the **`skip3DS` request flag, not by the card number** — send `"skip3DS": true` on `/initialize-payment` and the payment authorizes without a challenge (`requiresAction: false`), so there is no `actionUrl` to visit and a separate `/finalize-payment` step is not needed. Any sandbox PAN works. See [Test cards & sandbox](https://api.fynex.ai/payments-api/v2/docs#tag/test-cards).

---

## 4. Captures & refunds

### Q: Capture returns `409 "invalid status transition from <status> to capture"`

Capture has two hard pre-conditions:

1. The payment must have been created with `autoSettlement: false` (and `captureMode: "manual"`). If `autoSettlement` was `true`, a `409` with `"manual settlement required"` is returned.
2. The current payment status must be `authorized` or `provider_completed`. Any other status — including `settled`, `provider_pending`, or `failed` — returns the invalid-transition 409.
3. No refund can be pending or already successful for the payment. If a refund is pending, capture returns `409 "capture is not allowed while a refund is in progress"`; if any refund succeeded, capture returns `409 "capture is not allowed after a successful refund"`.

Check the payment's current status before calling capture. Use the GraphQL `genericPayment(id)` query to fetch it.

### Q: Refund returns `409 "refund is allowed only for provider_completed/settled/deposit_confirmed/refund_failed/refund_cancelled payments"`

The payment has not been captured or settled yet, or it is in a non-retryable terminal state. Refunds are available in `provider_completed`, `settled`, or `deposit_confirmed`; failed/cancelled refund attempts can also be retried from `refund_failed` or `refund_cancelled`.

### Q: What is `provider_completed`? I expected `captured`

There is no `captured` status in the Fynex payment lifecycle. After a successful capture, the payment moves to `provider_completed`. The statuses `captured`, `partially_captured`, and `partially_refunded` do not exist. See the [Payment Lifecycle](https://api.fynex.ai/payments-api/v2/docs#tag/payment-lifecycle) guide for the full status enum.

### Q: The capture/refund path parameter takes my numeric internal ID, right?

No. The `{id}` path parameter on `POST /payments/{id}/capture` and `POST /payments/{id}/refund` is the **string `externalOrderRef`** you passed to `/initialize-payment` — for example, `ORDER-1042`. It is not a numeric ID.

```bash
# Correct — use your externalOrderRef string
curl -X POST "$FYNEX_API/payments/ORDER-1042/capture" ...

# Wrong — do not use a numeric internal ID
curl -X POST "$FYNEX_API/payments/12345/capture" ...
```

### Q: I issued a partial refund successfully, but a second partial refund returns `409`

After the first refund call, the payment moves to `refund_pending`. You cannot issue another refund until the first one completes. A successful partial refund returns the parent payment to a refundable captured state; a full cumulative refund reaches `refunded`; failed/cancelled attempts move to `refund_failed`/`refund_cancelled` and can be retried.

---

## 5. Payouts

### Q: My `Idempotency-Key` header is being ignored on `POST /payouts`

`POST /payouts` does not read the `Idempotency-Key` HTTP header for deduplication. Pass idempotency as a body field named `idempotencyKey` instead:

```json
{
  "walletId": 15,
  "amountMinor": 199900,
  "currencyCode": "GBP",
  "idempotencyKey": "6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b"
}
```

All other POST endpoints in this API use the `Idempotency-Key` header. Payouts are the sole exception.

See [Payouts](https://api.fynex.ai/payments-api/v2/docs#tag/payouts) and [Idempotency & retries](https://api.fynex.ai/payments-api/v2/docs#tag/idempotency).

### Q: I'm getting `409 "insufficient balance"` but I can see funds in the wallet

Check the wallet's `availableBalanceMinor`, not its total balance. Held, pending, or reserved funds are not available for payout. Top up the wallet or wait for in-flight transactions to clear, then retry with the **same** `idempotencyKey` value.

### Q: Payout amounts — should I send major or minor units?

Payouts use **minor units** (`amountMinor`). For £19.99 send `1999`. This differs from every other endpoint in the API, which use major units. Double-check the field name: the payout body field is `amountMinor`, not `amount`.

### Q: The `failureMessage` field on `GET /payouts/{id}` is always empty — is that a bug?

Yes, this is a known implementation gap. `failureMessage` appears in the response schema but is never populated by the server. Use `failureCode` to detect and classify payout failures; do not rely on `failureMessage` for message text.

---

## 6. Verification & polling

### Q: How do I know when a payment succeeds if there are no webhooks?

Outbound webhooks are not yet available. Two options:

- **Polling:** query the GraphQL `genericPayment(id)` endpoint (requires a dashboard session cookie) until `status` reaches a terminal value.
- **SSE:** if the customer is sitting on a hosted checkout page, subscribe to the server-sent events stream at `GET /checkout/{session_id}/events` from the browser.

The Fynex dashboard polls every 5 seconds. See [Polling & SSE](https://api.fynex.ai/payments-api/v2/docs#tag/polling-sse).

### Q: I'm polling for `status === "succeeded"` and never getting a match

The status `succeeded` does not exist in the `GenericPaymentStatus` enum. Terminal success statuses are `settled` and `deposit_confirmed` (after settlement) or `provider_completed` (immediately after capture, before settlement). Check for these values instead. The full status enum is documented in [Payment Lifecycle](https://api.fynex.ai/payments-api/v2/docs#tag/payment-lifecycle).

### Q: The customer was redirected to my `returnUrls.success` page — can I fulfil the order now?

No. A user can navigate directly to your success URL without paying. The redirect is not authoritative. Always verify the payment state server-side via polling or SSE before dispatching goods or services.

### Q: How long should I poll before giving up?

For card payments: 10–15 minutes is a reasonable outer bound. Bank transfers may take longer. If the payment stays in a non-terminal state beyond your deadline, surface a `pending_review` UX state and alert your operations team — do not keep polling indefinitely. Once a payment reaches a terminal state (`settled`, `deposit_confirmed`, `failed`, `cancelled`, `refunded`), write it to your database and stop polling that record.

See [Polling & SSE](https://api.fynex.ai/payments-api/v2/docs#tag/polling-sse).

---

## 7. GraphQL / Dashboard API

### Q: GraphQL returns `401` with my Bearer token

`POST /dashboard/graphql` does not support Bearer token authentication. It requires an **HttpOnly session cookie** named `dashboard_session`. Obtain one by calling `POST /api/v1/login/dashboard` first:

```bash
curl -sc cookies.txt \
  -X POST https://api.fynex.ai/api/v1/login/dashboard \
  -H "Content-Type: application/json" \
  -d '{"email": "you@example.com", "password": "your_password"}'

# Then send GraphQL requests with the saved cookie
curl -b cookies.txt \
  -X POST https://api.fynex.ai/dashboard/graphql \
  -H "Content-Type: application/json" \
  -d '{"query": "{ payees(limit: 5) { id displayName } }"}'
```

See [GraphQL Authentication](https://api.fynex.ai/payments-api/v2/docs#tag/graphql-auth).

### Q: Sign-up succeeded but my next GraphQL request gets `401`

`POST /api/v1/onboarding/start` creates a **draft** user and sets the `dashboard_session` cookie, but the account has no organization until the wizard finishes with `POST /api/v1/onboarding/complete`. GraphQL operations that need an organization fail until then — complete the onboarding first (see [Account setup & onboarding](https://api.fynex.ai/payments-api/v2/docs#tag/onboarding)). For an existing account, `POST /api/v1/login/dashboard` obtains the cookie.

See [GraphQL Authentication](https://api.fynex.ai/payments-api/v2/docs#tag/graphql-auth).

### Q: My dashboard session disappeared and all GraphQL requests started returning `401`

Dashboard sessions are held in-memory on a single server node with a 24-hour TTL. A server restart invalidates all active sessions — you must re-authenticate. Your integration should handle the `401` response from `/dashboard/graphql` by re-running the login flow automatically.

### Q: Where is the GraphQL Playground?

A playground is available at `GET /dashboard/playground`. Note that the playground page itself has no auth gate — the authentication requirement applies to actual query execution, not to loading the playground UI.

---

## 8. Apple Pay & Google Pay (legacy surface)

### Q: Apple Pay merchant validation is failing in production

The Fynex dashboard fakes merchant validation by calling `completeMerchantValidation({})` with an empty object — this is a development shortcut that will not work against real Apple Pay. For production you must implement a **server-side** endpoint that contacts Apple's merchant validation URL using your Apple Pay merchant certificate and private key, and returns the opaque merchant session to the browser. Browsers cannot make this call directly due to CORS restrictions.

See [Apple Pay](https://api.fynex.ai/payments-api/v2/docs#tag/apple-pay).

### Q: Apple Pay only shows in Safari — is that expected?

Yes. `ApplePaySession` is available only in Safari on Apple devices (macOS + Safari or iOS/iPadOS). Chrome, Firefox, and other browsers do not support it. Always gate the Apple Pay button on `isApplePayAvailable()`:

```js
function isApplePayAvailable() {
  return typeof window !== 'undefined' &&
    'ApplePaySession' in window &&
    ApplePaySession.canMakePayments();
}
```

### Q: Google Pay tokenization is being rejected by Fynex

As of the recent Google Pay refactor, the `gateway` and `gatewayMerchantId` parameters are no longer chosen by integrators — Fynex's hosted checkout sets them server-side from per-deploy configuration so they always match what the upstream processor has enrolled. If you're seeing tokenization rejections on the hosted page, contact Fynex support with the GP merchant ID you registered in the Google Pay Business Console; if you're driving Google Pay outside the hosted page, that integration path is not supported as a public API today.

### Q: `isReadyToPay()` returns `true` but the Google Pay sheet shows no payment methods

`isReadyToPay()` checks whether the Google Pay API is available, not whether the user has saved cards. The sheet can open and show no cards. Gate showing the Google Pay button on `isReadyToPay()` but handle the empty-sheet case gracefully — do not treat it as an error.

### Q: The Google Pay flow returned `authorizationLink` — what do I do with it?

A 3DS step-up is required. Save `merchantRefNum`, `paymentHandleToken`, and `amount` to `localStorage`, redirect the customer's browser to the `authorizationLink`, and call `complete-google-pay-payment` once the issuer redirects them back to your return page. This is the same localStorage-bridge pattern used by the card 3DS flow.

See [Google Pay](https://api.fynex.ai/payments-api/v2/docs#tag/google-pay) and [3DS Authentication](https://api.fynex.ai/payments-api/v2/docs#tag/3ds).

---

## Cross-reference: common error messages

| HTTP status | Message | Cause | Guide |
|-------------|---------|-------|-------|
| `400` | `Idempotency-Key header is required` | Missing `Idempotency-Key` header on a POST endpoint | [Headers](https://api.fynex.ai/payments-api/v2/docs#tag/headers) |
| `400` | `Idempotency-Key must be a valid UUID` | Header value is not a UUID v4 | [Idempotency](https://api.fynex.ai/payments-api/v2/docs#tag/idempotency) |
| `400` | `currencyCode is required` | Missing `currencyCode` field | [Server-to-Server](https://api.fynex.ai/payments-api/v2/docs#tag/server-to-server) |
| `400` | `paymentMethod wero is not supported for currency EUR and country IT` | Wero is limited to EUR in Belgium, Germany, and France | [Alternative payment methods](https://api.fynex.ai/payments-api/v2/docs#tag/alternative-payment-methods-apm) |
| `400` | `invalid request body` | JSON parse failed — check `Content-Type: application/json` and body syntax | [Errors](https://api.fynex.ai/payments-api/v2/docs#tag/errors) |
| `401` | `seller authorization token is required` (plain text) | Missing or malformed `Authorization` header | [Authentication](https://api.fynex.ai/payments-api/v2/docs#tag/authentication) |
| `401` | `sellerAccountId is missing in auth context` | Missing or invalid `Idempotency-Key` on `POST /checkout` | [Hosted Checkout](https://api.fynex.ai/payments-api/v2/docs#tag/hosted-checkout) |
| `403` | `resource does not belong to this seller` | Token is valid but the resource belongs to a different seller account | [Authentication](https://api.fynex.ai/payments-api/v2/docs#tag/authentication) |
| `409` | `invalid status transition from <status> to capture` | Payment not in `authorized` or `provider_completed` state | [Captures & Refunds](https://api.fynex.ai/payments-api/v2/docs#tag/captures-refunds) |
| `409` | `capture is not allowed while a refund is in progress` | A refund has been reserved/submitted and has not terminalized yet | [Captures & Refunds](https://api.fynex.ai/payments-api/v2/docs#tag/captures-refunds) |
| `409` | `capture is not allowed after a successful refund` | A refund already succeeded for this payment, so capture is closed | [Captures & Refunds](https://api.fynex.ai/payments-api/v2/docs#tag/captures-refunds) |
| `409` | `refund is allowed only for provider_completed/settled/deposit_confirmed/refund_failed/refund_cancelled payments` | Payment is not in a refundable captured state, or the previous refund attempt is still pending/already fully refunded | [Captures & Refunds](https://api.fynex.ai/payments-api/v2/docs#tag/captures-refunds) |
| `409` | `insufficient balance` | Wallet `availableBalanceMinor` is below the requested payout amount | [Payouts](https://api.fynex.ai/payments-api/v2/docs#tag/payouts) |
| `409` | `payout with this idempotency key already exists` | Replay of a successful payout — fetch the existing payout instead of creating a new one | [Payouts](https://api.fynex.ai/payments-api/v2/docs#tag/payouts) |
| `502` | `upstream card processor returned 400` (payment row: `failureCode: 2002`, missing billing fields) | Missing `billingDetails.country` and/or `billingDetails.zip` on a card payment | [Server-to-Server](https://api.fynex.ai/payments-api/v2/docs#tag/server-to-server) |
| `failureCode: 2002` | `upstream card processor returned 400` after `/finalize-payment` (payment row: `failureStage: authorization`) | `/finalize-payment` called before the customer completed the 3DS challenge at `actionUrl` | [3DS Authentication](https://api.fynex.ai/payments-api/v2/docs#tag/3ds) |
| `502` | (any other) | Upstream processor error — safe to retry with the same idempotency key | [Errors](https://api.fynex.ai/payments-api/v2/docs#tag/errors) |

## See also

- **[Errors](https://api.fynex.ai/payments-api/v2/docs#tag/errors)** — Full HTTP status code and error body reference with recovery guidance.
- **[Polling & SSE](https://api.fynex.ai/payments-api/v2/docs#tag/polling-sse)** — How to verify payment and payout status without webhooks.
- **[Idempotency & retries](https://api.fynex.ai/payments-api/v2/docs#tag/idempotency)** — Make all mutating calls safe to retry with idempotency keys.
- **[Authentication & Tokens](https://api.fynex.ai/payments-api/v2/docs#tag/authentication)** — Bearer token provisioning, rotation, and common auth errors.
