> ## Documentation Index
> Fetch the complete documentation index at: https://api-docs-v3.openfx.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Errors

> v3 returns errors in a stable, machine-readable envelope with a code, type, human message, retry strategy, and per-code details.

Every v3 error response has the same shape, regardless of status code. Wire your client up once to switch on `error.code` and you're done.

<Note>
  **`type` is required on every v3 error.** Alongside `code` and `message`, the
  `type` field is now a **mandatory** part of the envelope — it is always
  present on every non-2xx response and is part of the OpenAPI `required` set.
  Clients can safely rely on `error.type` for coarse routing (auth vs validation
  vs `CONFLICT` vs rate-limited vs internal) without null-checks. This is a v3
  contract guarantee; treat a response without `type` as a transport-level bug,
  not a normal API outcome.
</Note>

```json theme={null}
{
  "error": {
    "code": "QUOTE_EXPIRED",
    "type": "CONFLICT",
    "message": "The quoted rate has expired. Request a new quote.",
    "retryStrategy": "FIX_AND_RESUBMIT",
    "details": {
      "quoteId": "qte_3FfGK34vwMvVFDedyb2nkf",
      "expiredAt": "2026-04-28T10:00:00.000Z"
    }
  }
}
```

<Warning>
  **The trace ID is in the `X-Trace-Id` response header, not the error body.**
  Every response (success and error) carries it. Log it alongside the request
  payload; support uses it as the first input on every triage. See [Metadata &
  Tracing](/v3/metadata-and-tracing).
</Warning>

## Why this shape

v2 errors look like `{ "status": "error", "message": "Insufficient balance" }`. That has three problems:

1. **No code.** Clients have to string-match `message`, which breaks the moment a copy edit lands.
2. **No trace ID.** Triage starts with "send us your logs."
3. **No structure.** "Insufficient balance" tells you what's wrong, but not by how much, in which currency.

v3 fixes all three.

## Error envelope fields

| Field           | Type   | Required            | Notes                                                                                                                                                                                                                                  |
| --------------- | ------ | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `code`          | string | yes                 | Stable, machine-readable. Format `<PRODUCT>_<ERROR>`. Never renamed or removed without a major version bump.                                                                                                                           |
| `type`          | string | **yes (mandatory)** | Coarse category. Always present on every error response. One of: `VALIDATION_ERROR`, `AUTHENTICATION_ERROR`, `AUTHORIZATION_ERROR`, `NOT_FOUND`, `CONFLICT`, `RATE_LIMITED`, `INTERNAL_ERROR`.                                         |
| `message`       | string | yes                 | Human-readable. **May change** between versions. Switch on `code`, not `message`.                                                                                                                                                      |
| `retryStrategy` | string | **yes (mandatory)** | Machine-readable recovery hint, independent of `code` and `type`. Always present. One of: `RETRY_SAME_KEY`, `WAIT_THEN_RETRY_SAME_KEY`, `FIX_AND_RESUBMIT`, `CHECK_STATE_THEN_RETRY`, `TERMINAL`. See [Triage matrix](#triage-matrix). |
| `details`       | object | **yes**             | Per-code, structured context. Shape varies by `code`. **Always present** — `{}` when there is no structured context for this code.                                                                                                     |

`type`, `retryStrategy`, and `details` are required by the example contract; `details` is always present (`{}` when there is no structured context). Errors are returned as `application/json` — v3 does not use `application/problem+json`; the envelope above is the contract.

<Note>
  **Error responses never carry a `metadata` field.** Client-supplied
  request-body `metadata` is echoed back only on **success** responses. On any
  non-2xx, the envelope is exactly `code`, `type`, `message`, `retryStrategy`,
  and `details` — don't look for your request `metadata` on an error.
</Note>

## Triage matrix

`error.retryStrategy` is the direct, server-declared signal for how to react — read it instead of pattern-matching on `error.code` or HTTP status. It's one of five values, and every error response carries one:

| `retryStrategy`            | Meaning                                                                                                                                                                                                                                       | Example codes                                                                                        |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `RETRY_SAME_KEY`           | Safe to retry immediately with the same `Idempotency-Key`. Reserved for future use — no current code emits this value, but clients must still handle it.                                                                                      | *(none currently)*                                                                                   |
| `WAIT_THEN_RETRY_SAME_KEY` | Transient backpressure, then retry with the **same** key. `RATE_LIMIT_EXCEEDED` and `IDEMPOTENCY_IN_FLIGHT` carry `Retry-After` (seconds) — wait that long. `IDEMPOTENCY_UNAVAILABLE` carries no `Retry-After`; apply your own short backoff. | `RATE_LIMIT_EXCEEDED`, `IDEMPOTENCY_IN_FLIGHT`, `IDEMPOTENCY_UNAVAILABLE`                            |
| `FIX_AND_RESUBMIT`         | Terminal in its current form. Fix the request or wait out the blocking state, then resubmit with a **new** key.                                                                                                                               | `VALIDATION_BODY_FAILED`, `WITHDRAWAL_INSUFFICIENT_BALANCE`, `QUOTE_EXPIRED`, `IDEMPOTENCY_MISMATCH` |
| `CHECK_STATE_THEN_RETRY`   | Indeterminate write. `GET /v3/fx/{resource}/{id}` first — using `details.resourceId` when present — before deciding whether to retry.                                                                                                         | `TRADE_EXECUTION_FAILED`, `WITHDRAWAL_INITIATION_FAILED`, `INTERNAL_ERROR`                           |
| `TERMINAL`                 | Do not retry. Auth failures, `*_NOT_FOUND`, and account-level policy blocks.                                                                                                                                                                  | `AUTH_TOKEN_EXPIRED`, `TRADE_NOT_FOUND`, `TRADE_DISABLED`, `WITHDRAWAL_DISABLED_FOR_ACCOUNT`         |

Clients must default-handle unknown future `retryStrategy` values as `TERMINAL` — the same open-vocabulary rule as `error.code` and `error.type` (see [Client error modeling](#client-error-modeling)).

The table below cross-references the same catalog by HTTP status, for clients that key off status first:

| HTTP status                  | `type` examples                | Behavior bucket                                                                                                                                                                                                                       | Example codes                                                                                                                                                      |
| ---------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **400** schema               | `VALIDATION_ERROR`             | Your JSON is malformed or has wrong field types. Fix and resubmit.                                                                                                                                                                    | `VALIDATION_BODY_FAILED`, `VALIDATION_QUERY_FAILED`, `VALIDATION_PATH_FAILED`, `VALIDATION_CONTENT_TYPE`                                                           |
| **401** auth                 | `AUTHENTICATION_ERROR`         | Token missing, invalid, or expired. Mint a fresh JWT.                                                                                                                                                                                 | `AUTH_TOKEN_EXPIRED`, `AUTH_API_KEY_INVALID`                                                                                                                       |
| **403** authz                | `AUTHORIZATION_ERROR`          | Authenticated, but not allowed. Surface to user or check key/IP/scope/env/product entitlement.                                                                                                                                        | `AUTH_INSUFFICIENT_SCOPE`, `AUTH_FORBIDDEN_IP`, `SANDBOX_MODE_REQUIRED`, `TRADE_DISABLED`, `FEATURE_NOT_ENABLED`                                                   |
| **404** not found            | `NOT_FOUND`                    | Resource doesn't exist. Treat as terminal in your app.                                                                                                                                                                                | `TRADE_NOT_FOUND`, `WITHDRAWAL_NOT_FOUND`, `DEPOSIT_NOT_FOUND`                                                                                                     |
| **405** method               | `VALIDATION_ERROR`             | Wrong HTTP method on this path. Check the API reference.                                                                                                                                                                              | `METHOD_NOT_ALLOWED`                                                                                                                                               |
| **409** state conflict       | `CONFLICT`                     | Well-formed request blocked by current state. Resolve state, then retry as a new operation.                                                                                                                                           | `WITHDRAWAL_INSUFFICIENT_BALANCE`, `TRADE_INSUFFICIENT_BALANCE`, `IDEMPOTENCY_IN_FLIGHT`, `QUOTE_EXPIRED`, `QUOTE_ALREADY_CONSUMED`, `TRADE_CREDIT_LIMIT_EXCEEDED` |
| **413** payload too large    | `VALIDATION_ERROR`             | The request body exceeds the endpoint's payload limit. Reduce the body size and resubmit.                                                                                                                                             | `VALIDATION_PAYLOAD_TOO_LARGE`                                                                                                                                     |
| **422** semantic             | `VALIDATION_ERROR`, `CONFLICT` | Well-formed input that violates a business rule. Fix the value and resubmit.                                                                                                                                                          | `WITHDRAWAL_INVALID_AMOUNT_PRECISION`, `TRADE_AMOUNT_ABOVE_LIMIT`, `IDEMPOTENCY_MISMATCH`                                                                          |
| **428** missing precondition | `VALIDATION_ERROR`             | A required precondition header is missing. Add it and retry with the SAME key.                                                                                                                                                        | `IDEMPOTENCY_KEY_MISSING`                                                                                                                                          |
| **429** rate limited         | `RATE_LIMITED`                 | Wait `Retry-After` seconds, then retry with the same key.                                                                                                                                                                             | `RATE_LIMIT_EXCEEDED`                                                                                                                                              |
| **500** internal             | `INTERNAL_ERROR`               | Server error. For write paths that echo `details.resourceId` (`TRADE_EXECUTION_FAILED`, `WITHDRAWAL_INITIATION_FAILED`), check state by ID before retrying — see [Retrying trade execution failed](#retrying-trade-execution-failed). | `INTERNAL_ERROR`, `TRADE_EXECUTION_FAILED`, `WITHDRAWAL_INITIATION_FAILED`                                                                                         |
| **503** unavailable          | `INTERNAL_ERROR`               | A required upstream dependency (the idempotency service) is temporarily unreachable. Wait, then retry with the same key.                                                                                                              | `IDEMPOTENCY_UNAVAILABLE`                                                                                                                                          |

## Decision flow

Use this as a mental model for what to do when a non-2xx lands. Reading `error.retryStrategy` directly (see the [triage matrix](#triage-matrix)) gets you the same answer without maintaining a code-pattern list; the flow below is for clients that branch on `error.code` and `type` instead.

```mermaid theme={null}
%%{init: {'theme':'base','themeVariables':{'fontFamily':'Inter, system-ui, sans-serif','fontSize':'12px','lineColor':'#299f68','primaryColor':'#ffffff','primaryTextColor':'#114330','primaryBorderColor':'#299f68'}}}%%
flowchart TD
  Start([API call returns non-2xx]) --> ReadCode{{Read error.code}}
  ReadCode --> Auth401{401 AUTH_TOKEN_*<br/>AUTH_API_KEY_INVALID<br/>AUTH_API_KEY_MISSING?}
  ReadCode --> Authz403{403 AUTH_FORBIDDEN_IP<br/>AUTH_INSUFFICIENT_SCOPE<br/>SANDBOX_MODE_REQUIRED<br/>*_KEY_*_REQUIRED?}
  ReadCode --> Schema400{400 VALIDATION_BODY_FAILED<br/>VALIDATION_QUERY_FAILED<br/>VALIDATION_PATH_FAILED?}
  ReadCode --> Semantic422{422 *_INVALID_AMOUNT_PRECISION<br/>TRADE_AMOUNT_*_LIMIT<br/>IDEMPOTENCY_MISMATCH?}
  ReadCode --> Precondition428{428 IDEMPOTENCY_KEY_MISSING?}
  ReadCode --> Idem{IDEMPOTENCY_IN_FLIGHT?}
  ReadCode --> Rate{429 RATE_LIMIT_EXCEEDED?}
  ReadCode --> NotFound{404 *_NOT_FOUND?}
  ReadCode --> StateConflict{409 *_INSUFFICIENT_*<br/>*_LIMIT_REACHED<br/>QUOTE_EXPIRED<br/>*_ADDRESS_NOT_VERIFIED?}
  ReadCode --> ExecFailed{500 TRADE_EXECUTION_FAILED<br/>WITHDRAWAL_INITIATION_FAILED?}
  ReadCode --> Internal{500 INTERNAL_ERROR?}

  Auth401 -->|yes| MintToken[/"Mint a fresh JWT.<br/>If it still fails, check API key validity in the dashboard."/]
  Authz403 -->|yes| SurfaceUser[/"Surface to user.<br/>Check IP allowlist, key scope, environment header."/]
  Schema400 -->|yes| FixSchema[/"Fix the request shape.<br/>Resubmit with NEW idempotency key."/]
  Semantic422 -->|yes| FixSemantic[/"Fix the business-rule violation in details.<br/>Resubmit with NEW key.<br/>IDEMPOTENCY_MISMATCH = reused key with different body."/]
  Precondition428 -->|yes| AddHeader[/"Add the Idempotency-Key header.<br/>Retry with SAME key."/]
  Idem -->|yes| WaitRetry[/"Wait, then retry<br/>with the SAME key."/]
  Rate -->|yes| WaitRetry
  NotFound -->|yes| Terminal[/"Terminal.<br/>Treat as not found in your app."/]
  StateConflict -->|yes| ResolveState[/"Resolve underlying state, then retry as NEW operation with NEW key.<br/>For QUOTE_EXPIRED specifically: re-quote."/]
  ExecFailed -->|yes| CheckState[/"GET the resource by ID first.<br/>If it exists, the write succeeded.<br/>If 404, safe to retry with SAME key."/]
  Internal -->|yes| CheckState

  classDef entry fill:#114330,stroke:#114330,stroke-width:2px,color:#ffffff,rx:6,ry:6
  classDef decision fill:#ffffff,stroke:#299f68,stroke-width:1.5px,color:#114330
  classDef fix fill:#d7f4e1,stroke:#299f68,stroke-width:1.5px,color:#114330
  classDef wait fill:#fef3c7,stroke:#d97706,stroke-width:1.5px,color:#92400e
  classDef investigate fill:#114330,stroke:#114330,stroke-width:1.5px,color:#ffffff
  classDef terminal fill:#fee2e2,stroke:#dc2626,stroke-width:1.5px,color:#7f1d1d

  class Start,ReadCode entry
  class Auth401,Authz403,Schema400,Semantic422,Precondition428,Idem,Rate,NotFound,StateConflict,ExecFailed,Internal decision
  class MintToken,SurfaceUser,FixSchema,FixSemantic,AddHeader,ResolveState fix
  class WaitRetry wait
  class CheckState investigate
  class Terminal terminal
```

When in doubt, read `error.retryStrategy` directly. Switch on the specific `error.code` for finer-grained handling, and fall back to the `type` column for categorisation.

## HTTP status mapping

`type` is the coarse routing taxonomy. HTTP status is the wire-level signal. They line up like this:

| `type`                 | Status  | Used for                                                                                                                                                                  |
| ---------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `VALIDATION_ERROR`     | **400** | Schema / syntax violations: malformed JSON, wrong types, missing required fields                                                                                          |
| `VALIDATION_ERROR`     | **422** | Semantic violations: well-formed input that breaks a business rule, cross-field constraint, or precision rule                                                             |
| `VALIDATION_ERROR`     | **428** | Missing required precondition header (`Idempotency-Key`)                                                                                                                  |
| `VALIDATION_ERROR`     | **405** | HTTP method not allowed for this path                                                                                                                                     |
| `VALIDATION_ERROR`     | **413** | Request body exceeds the endpoint's payload size limit                                                                                                                    |
| `AUTHENTICATION_ERROR` | **401** | Token missing, invalid, or expired                                                                                                                                        |
| `AUTHORIZATION_ERROR`  | **403** | Authenticated, but not allowed: wrong scope, wrong env, IP blocked, account disabled                                                                                      |
| `NOT_FOUND`            | **404** | Resource doesn't exist                                                                                                                                                    |
| `NOT_FOUND`            | **410** | Endpoint has been sunset (v1 after December 31, 2026). `Sunset` + `Link` headers carry the migration info                                                                 |
| `CONFLICT`             | **409** | State-driven conflict: well-formed request blocked by current system state (insufficient balance, expired quote, in-flight idempotency key, credit limit reached)         |
| `CONFLICT`             | **422** | `IDEMPOTENCY_MISMATCH`: same key reused with different parameters                                                                                                         |
| `RATE_LIMITED`         | **429** | Per-org or per-key rate limit                                                                                                                                             |
| `INTERNAL_ERROR`       | **500** | Server error. Read the affected resource state before retrying a write, or retry a read with backoff. Write-path codes may echo `details.resourceId` for the state check. |
| `INTERNAL_ERROR`       | **503** | A required upstream dependency (the idempotency service) is temporarily unreachable                                                                                       |

**Why two statuses for `VALIDATION_ERROR`?** 400 means "your input didn't pass the schema." 422 means "your input is well-formed JSON of the right types, but it violates a business rule" (a withdrawal amount with too many decimals, a trade above your account limit, an idempotency-key reuse with mismatched body). Modern clients can build different UI flows for the two: 400 → "fix the field"; 422 → "tell the user about the rule."

A common confusion: **negative request-body amount → 400, not 422.** Request amount fields (e.g. `WithdrawalCreateRequest.withdrawalAmount`) are typed as `PositiveAmount` and the schema regex `^[0-9]+(\.[0-9]{1,8})?$` rejects the leading sign. So `POST /v3/fx/withdrawals` with `"withdrawalAmount": "-1.00"` returns `400 VALIDATION_BODY_FAILED` (schema), not `422 WITHDRAWAL_INVALID_AMOUNT_PRECISION` (semantic). The precision codes (`WITHDRAWAL_INVALID_AMOUNT_PRECISION`, etc.) are only reached by inputs that already pass the schema regex — i.e. positive numbers with too many fractional digits.

**Why two statuses for `CONFLICT`?** 409 is the state-driven conflict bucket (a fact about the system blocks the request). 422 is reserved for the one case where idempotency-key semantics are violated.

Clients should switch on `error.code` for branching logic and only fall back to `type` for categorisation.

### 404 vs 403: when each fires

The boundary between 404 ("doesn't exist") and 403 ("not allowed") is precise in v3. Use this table when classifying a non-2xx that landed before your business logic ran:

| Scenario                                                                                              | Status | Code                                                                                                              |
| ----------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------- |
| The path doesn't exist in v3 at all                                                                   | 404    | `ROUTE_NOT_FOUND`                                                                                                 |
| The path exists, but the resource ID doesn't — or it belongs to another org (we don't disclose which) | 404    | `*_NOT_FOUND` (e.g. `TRADE_NOT_FOUND`, `QUOTE_NOT_FOUND`, `WITHDRAWAL_NOT_FOUND`, `WITHDRAWAL_ADDRESS_NOT_FOUND`) |
| The path exists, but a specific resource is toggled off for your org                                  | 403    | `TRADE_CURRENCY_NOT_ENABLED_FOR_ACCOUNT`, `TRADE_PAIR_NOT_ENABLED_FOR_ACCOUNT`, `WITHDRAWAL_ACCOUNT_NOT_LIVE`     |
| The path exists, but the whole domain is disabled for your org                                        | 403    | `TRADE_DISABLED`, `WITHDRAWAL_DISABLED_FOR_ACCOUNT`                                                               |
| The path exists, but your API key is missing the required scope                                       | 403    | `AUTH_INSUFFICIENT_SCOPE`                                                                                         |

A 404 means the request never reached an entitlement check — it was rejected at routing or hidden for leakage prevention. A 403 means the request reached a handler that knows who you are and is telling you what to fix (or who to contact). Switch on the specific `error.code` rather than the status to decide the next action.

**Product-level entitlement uses a two-word convention.** `[PRODUCT]_DISABLED` means access has been revoked or blocked; this catalog defines `TRADE_DISABLED` and `WITHDRAWAL_DISABLED_FOR_ACCOUNT` (both 403). Resource-level toggles keep their own codes (`TRADE_CURRENCY_NOT_ENABLED_FOR_ACCOUNT`, `TRADE_PAIR_NOT_ENABLED_FOR_ACCOUNT`). Only codes in the generated catalog are part of the contract.

## Code stability guarantee

Once a code ships in v3, **it does not get renamed or removed without a major version bump.** New codes may be introduced in a minor version, documented in the changelog.

### Client error modeling

**Treat `error.code`, `error.type`, and `error.retryStrategy` as non-exhaustive (open) vocabularies.** All three can gain new members in a minor version, so model each as an open enum: switch on the value but **always keep a `default` branch**. When you hit a value you don't recognize:

* An **unknown `error.type`** should be handled as `INTERNAL_ERROR` (treat it as a server-side category you don't yet model).
* An **unknown `error.code`** should bubble up with its `X-Trace-Id` so a newly-added code never silently breaks your client.
* An **unknown `error.retryStrategy`** should be handled as `TERMINAL` (the safest default — don't retry automatically).

This keeps your client forward-compatible: a code, type, or retry strategy added after you shipped degrades to a safe default instead of an unhandled branch.

**Every `details` object is typed and closed.** Each code maps to a dedicated schema through `x-openfx-error-catalog`; codes without additional context reference the shared closed `EmptyErrorDetails` schema. Adding an optional detail field is additive. Removing or renaming a field, or making an optional field required, is a breaking change.

## Error code catalog

The sample contract defines **62 error codes**. The OpenAPI extension `x-openfx-error-catalog` is the source of truth for status, client action, lifecycle, and the closed `details` schema.

<div id="authentication-authorization" />

<div id="validation" />

<div id="idempotency" />

<div id="routing" />

<div id="rate-limiting" />

<div id="trade" />

<div id="quotes" />

<div id="deposits" />

<div id="withdrawal" />

<div id="withdrawal-accounts" />

<div id="exposures" />

<div id="platform" />

<div id="lifecycle" />

<Tabs>
  <Tab title="Authentication">
    | Code                            | Status | Type                   | Trigger                                                                         | Details                   | Client action                                                    |
    | ------------------------------- | -----: | ---------------------- | ------------------------------------------------------------------------------- | ------------------------- | ---------------------------------------------------------------- |
    | `AUTH_TOKEN_MISSING`            |    401 | `AUTHENTICATION_ERROR` | The Authorization header is missing.                                            | `{}`                      | Do not retry automatically; correct access or surface the error. |
    | `AUTH_TOKEN_INVALID`            |    401 | `AUTHENTICATION_ERROR` | The bearer token or request signature is malformed or fails verification.       | `{ reason? }`             | Do not retry automatically; correct access or surface the error. |
    | `AUTH_TOKEN_EXPIRED`            |    401 | `AUTHENTICATION_ERROR` | The bearer token expiry time is in the past.                                    | `{ expiredAt? }`          | Do not retry automatically; correct access or surface the error. |
    | `AUTH_TOKEN_INVALID_CONFIG`     |    401 | `AUTHENTICATION_ERROR` | The bearer token claims or validity window violate the authentication contract. | `{ reason, maxSeconds? }` | Do not retry automatically; correct access or surface the error. |
    | `AUTH_API_KEY_MISSING`          |    401 | `AUTHENTICATION_ERROR` | The bearer token does not reference an API key.                                 | `{}`                      | Do not retry automatically; correct access or surface the error. |
    | `AUTH_API_KEY_INVALID`          |    401 | `AUTHENTICATION_ERROR` | The referenced API key does not exist or is inactive.                           | `{}`                      | Do not retry automatically; correct access or surface the error. |
    | `AUTH_API_KEY_SANDBOX_REQUIRED` |    403 | `AUTHORIZATION_ERROR`  | Sandbox traffic uses a Live API key.                                            | `{}`                      | Do not retry automatically; correct access or surface the error. |
    | `AUTH_API_KEY_LIVE_REQUIRED`    |    403 | `AUTHORIZATION_ERROR`  | Live traffic uses a Sandbox API key.                                            | `{}`                      | Do not retry automatically; correct access or surface the error. |
    | `SANDBOX_MODE_REQUIRED`         |    403 | `AUTHORIZATION_ERROR`  | A Sandbox request omits the required x-app-mode value.                          | `{}`                      | Do not retry automatically; correct access or surface the error. |
    | `AUTH_FORBIDDEN_IP`             |    403 | `AUTHORIZATION_ERROR`  | The source IP address is outside the API key allowlist.                         | `{ ip? }`                 | Do not retry automatically; correct access or surface the error. |
    | `AUTH_INSUFFICIENT_SCOPE`       |    403 | `AUTHORIZATION_ERROR`  | The API key lacks a scope required by the operation.                            | `{ requiredScope }`       | Do not retry automatically; correct access or surface the error. |
  </Tab>

  <Tab title="Validation">
    | Code                           | Status | Type               | Trigger                                                                                  | Details                       | Client action                                                       |
    | ------------------------------ | -----: | ------------------ | ---------------------------------------------------------------------------------------- | ----------------------------- | ------------------------------------------------------------------- |
    | `VALIDATION_QUERY_FAILED`      |    400 | `VALIDATION_ERROR` | One or more query parameters fail schema validation.                                     | `{ issues, firstIssuePath? }` | Correct the request or blocking state, then submit a new operation. |
    | `VALIDATION_PATH_FAILED`       |    400 | `VALIDATION_ERROR` | One or more path parameters fail schema validation.                                      | `{ issues, firstIssuePath? }` | Correct the request or blocking state, then submit a new operation. |
    | `VALIDATION_BODY_FAILED`       |    400 | `VALIDATION_ERROR` | The request body fails schema validation.                                                | `{ issues, firstIssuePath? }` | Correct the request or blocking state, then submit a new operation. |
    | `VALIDATION_CONTENT_TYPE`      |    400 | `VALIDATION_ERROR` | The request's Content-Type is not application/json, or specifies an unsupported charset. | `{}`                          | Correct the request or blocking state, then submit a new operation. |
    | `VALIDATION_PAYLOAD_TOO_LARGE` |    413 | `VALIDATION_ERROR` | The request body exceeds the maximum allowed payload size.                               | `{}`                          | Correct the request or blocking state, then submit a new operation. |
    | `METHOD_NOT_ALLOWED`           |    405 | `VALIDATION_ERROR` | The HTTP method is not supported on the request path.                                    | `{ method, allowed }`         | Correct the request or blocking state, then submit a new operation. |
  </Tab>

  <Tab title="Idempotency">
    | Code                      | Status | Type               | Trigger                                                                                                    | Details                   | Client action                                                       |
    | ------------------------- | -----: | ------------------ | ---------------------------------------------------------------------------------------------------------- | ------------------------- | ------------------------------------------------------------------- |
    | `IDEMPOTENCY_KEY_MISSING` |    428 | `VALIDATION_ERROR` | A write request omits the Idempotency-Key header.                                                          | `{ endpoint }`            | Correct the request or blocking state, then submit a new operation. |
    | `IDEMPOTENCY_KEY_INVALID` |    400 | `VALIDATION_ERROR` | The Idempotency-Key value does not match ^\[a-zA-Z0-9\_-]{1,255}\$.                                        | `{}`                      | Correct the request or blocking state, then submit a new operation. |
    | `IDEMPOTENCY_IN_FLIGHT`   |    409 | `CONFLICT`         | Another request with the same idempotency key is still processing.                                         | `{}`                      | Wait as directed, then retry with the same idempotency key.         |
    | `IDEMPOTENCY_MISMATCH`    |    422 | `CONFLICT`         | An idempotency key is reused with a different request body.                                                | `{ originalRequestHash }` | Correct the request or blocking state, then submit a new operation. |
    | `IDEMPOTENCY_UNAVAILABLE` |    503 | `INTERNAL_ERROR`   | The idempotency service is temporarily unreachable, so the request's Idempotency-Key could not be checked. | `{}`                      | Wait as directed, then retry with the same idempotency key.         |
  </Tab>

  <Tab title="Routing">
    | Code              | Status | Type        | Trigger                                     | Details    | Client action                                                    |
    | ----------------- | -----: | ----------- | ------------------------------------------- | ---------- | ---------------------------------------------------------------- |
    | `ROUTE_NOT_FOUND` |    404 | `NOT_FOUND` | The request path does not match a v3 route. | `{ path }` | Do not retry automatically; correct access or surface the error. |
  </Tab>

  <Tab title="Rate limiting">
    | Code                  | Status | Type           | Trigger                                                | Details | Client action                                               |
    | --------------------- | -----: | -------------- | ------------------------------------------------------ | ------- | ----------------------------------------------------------- |
    | `RATE_LIMIT_EXCEEDED` |    429 | `RATE_LIMITED` | The organization or API key exceeds its request limit. | `{}`    | Wait as directed, then retry with the same idempotency key. |
  </Tab>

  <Tab title="Trading">
    | Code                                     | Status | Type                  | Trigger                                                                                 | Details                                               | Client action                                                       |
    | ---------------------------------------- | -----: | --------------------- | --------------------------------------------------------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------- |
    | `TRADE_NOT_FOUND`                        |    404 | `NOT_FOUND`           | The trade identifier is unknown or inaccessible to the caller.                          | `{ tradeId }`                                         | Do not retry automatically; correct access or surface the error.    |
    | `TRADE_NOT_REVERSIBLE`                   |    409 | `CONFLICT`            | The trade is outside its reversal window or has already been reversed or settled.       | `{}`                                                  | Correct the request or blocking state, then submit a new operation. |
    | `TRADE_NOT_SETTLEABLE`                   |    409 | `CONFLICT`            | The trade isn't in a settleable state (already settled, not yet executed, or reversed). | `{}`                                                  | Correct the request or blocking state, then submit a new operation. |
    | `TRADE_NOT_MANUAL`                       |    409 | `CONFLICT`            | The trade is not a manual trade and cannot be settled through this endpoint.            | `{}`                                                  | Do not retry automatically; correct access or surface the error.    |
    | `TRADE_ALREADY_SETTLED`                  |    409 | `CONFLICT`            | The trade has already been settled.                                                     | `{}`                                                  | Do not retry automatically; correct access or surface the error.    |
    | `TRADE_EXECUTION_FAILED`                 |    500 | `INTERNAL_ERROR`      | Trade execution fails after the write is accepted.                                      | `{ resourceId, timeoutMs? }`                          | Read the resource state before deciding whether to retry.           |
    | `TRADE_DUPLICATE_CLIENT_REFERENCE`       |    409 | `CONFLICT`            | A trade already exists with the given clientReferenceId.                                | `{}`                                                  | Correct the request or blocking state, then submit a new operation. |
    | `TRADE_DISABLED`                         |    403 | `AUTHORIZATION_ERROR` | Trading is disabled for the organization.                                               | `{}`                                                  | Do not retry automatically; correct access or surface the error.    |
    | `TRADE_AMOUNT_ABOVE_LIMIT`               |    422 | `VALIDATION_ERROR`    | The trade amount exceeds the organization's maximum.                                    | `{ requestedAmount, currency, limitAmount }`          | Correct the request or blocking state, then submit a new operation. |
    | `TRADE_AMOUNT_BELOW_MINIMUM`             |    422 | `VALIDATION_ERROR`    | The trade amount is below the organization's minimum.                                   | `{ requestedAmount, currency, minimumAmount }`        | Correct the request or blocking state, then submit a new operation. |
    | `TRADE_PAIR_VOLUME_LIMIT_REACHED`        |    409 | `CONFLICT`            | The currency pair reaches its global net-position limit.                                | `{ pair }`                                            | Do not retry automatically; correct access or surface the error.    |
    | `TRADE_CREDIT_LIMIT_EXCEEDED`            |    409 | `CONFLICT`            | The trade would exceed the organization's credit limit.                                 | `{ usedAmount, limitAmount, currency }`               | Correct the request or blocking state, then submit a new operation. |
    | `TRADE_INSUFFICIENT_BALANCE`             |    409 | `CONFLICT`            | The sell-side balance is insufficient at execution time.                                | `{ requiredAmount, availableAmount, currency, side }` | Correct the request or blocking state, then submit a new operation. |
    | `TRADE_CURRENCY_NOT_SUPPORTED`           |    400 | `VALIDATION_ERROR`    | The requested currency is not supported.                                                | `{ currency }`                                        | Correct the request or blocking state, then submit a new operation. |
    | `TRADE_CURRENCY_NOT_ENABLED_FOR_ACCOUNT` |    403 | `AUTHORIZATION_ERROR` | The requested currency is not enabled for the organization.                             | `{ currency }`                                        | Do not retry automatically; correct access or surface the error.    |
    | `TRADE_PAIR_NOT_SUPPORTED`               |    400 | `VALIDATION_ERROR`    | The requested currency pair is not supported.                                           | `{ pair }`                                            | Correct the request or blocking state, then submit a new operation. |
    | `TRADE_PAIR_NOT_ENABLED_FOR_ACCOUNT`     |    403 | `AUTHORIZATION_ERROR` | The requested currency pair is not enabled for the organization.                        | `{ pair }`                                            | Do not retry automatically; correct access or surface the error.    |
  </Tab>

  <Tab title="Quotes">
    | Code                           | Status | Type               | Trigger                                                                                                                       | Details                   | Client action                                                       |
    | ------------------------------ | -----: | ------------------ | ----------------------------------------------------------------------------------------------------------------------------- | ------------------------- | ------------------------------------------------------------------- |
    | `QUOTE_INVALID_REFERENCE_UNIT` |    400 | `VALIDATION_ERROR` | The reference unit does not match either currency in the requested pair.                                                      | `{}`                      | Correct the request or blocking state, then submit a new operation. |
    | `QUOTE_EXPIRED`                |    409 | `CONFLICT`         | The quote expires before trade execution.                                                                                     | `{ quoteId, expiredAt }`  | Correct the request or blocking state, then submit a new operation. |
    | `QUOTE_ALREADY_CONSUMED`       |    409 | `CONFLICT`         | The quote was consumed by an earlier successful trade.                                                                        | `{ quoteId, consumedAt }` | Correct the request or blocking state, then submit a new operation. |
    | `QUOTE_NOT_FOUND`              |    404 | `NOT_FOUND`        | The quote identifier is unknown or inaccessible to the caller, or more than 2 days have passed since the quote's `createdAt`. | `{ quoteId }`             | Do not retry automatically; correct access or surface the error.    |
  </Tab>

  <Tab title="Deposits">
    | Code                | Status | Type        | Trigger                                                          | Details         | Client action                                                    |
    | ------------------- | -----: | ----------- | ---------------------------------------------------------------- | --------------- | ---------------------------------------------------------------- |
    | `DEPOSIT_NOT_FOUND` |    404 | `NOT_FOUND` | The deposit identifier is unknown or inaccessible to the caller. | `{ depositId }` | Do not retry automatically; correct access or surface the error. |
  </Tab>

  <Tab title="Withdrawals">
    | Code                                   | Status | Type                  | Trigger                                                                      | Details                                         | Client action                                                       |
    | -------------------------------------- | -----: | --------------------- | ---------------------------------------------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------- |
    | `WITHDRAWAL_NOT_FOUND`                 |    404 | `NOT_FOUND`           | The withdrawal identifier is unknown or inaccessible to the caller.          | `{}`                                            | Do not retry automatically; correct access or surface the error.    |
    | `WITHDRAWAL_INITIATION_FAILED`         |    500 | `INTERNAL_ERROR`      | Withdrawal initiation fails after the write is accepted.                     | `{ resourceId, timeoutMs? }`                    | Read the resource state before deciding whether to retry.           |
    | `WITHDRAWAL_INVALID_AMOUNT_PRECISION`  |    422 | `VALIDATION_ERROR`    | The withdrawal amount exceeds the currency's decimal precision.              | `{ maxDecimals, currency }`                     | Correct the request or blocking state, then submit a new operation. |
    | `WITHDRAWAL_INSUFFICIENT_BALANCE`      |    409 | `CONFLICT`            | The available balance is lower than the withdrawal amount.                   | `{ requiredAmount, availableAmount, currency }` | Correct the request or blocking state, then submit a new operation. |
    | `WITHDRAWAL_ADDRESS_NOT_VERIFIED`      |    409 | `CONFLICT`            | The destination account is inactive or unverified.                           | `{ accountId }`                                 | Correct the request or blocking state, then submit a new operation. |
    | `WITHDRAWAL_ADDRESS_CHECKSUM_FAILED`   |    422 | `VALIDATION_ERROR`    | The destination address checksum is invalid for the selected network.        | `{ address, network }`                          | Correct the request or blocking state, then submit a new operation. |
    | `WITHDRAWAL_CURRENCY_NOT_SUPPORTED`    |    400 | `VALIDATION_ERROR`    | The requested currency is not supported for withdrawals.                     | `{}`                                            | Correct the request or blocking state, then submit a new operation. |
    | `WITHDRAWAL_DISABLED_FOR_ACCOUNT`      |    403 | `AUTHORIZATION_ERROR` | Withdrawals are currently disabled for the caller's account.                 | `{}`                                            | Do not retry automatically; correct access or surface the error.    |
    | `WITHDRAWAL_PENDING_DEPOSIT_REQUIRED`  |    409 | `CONFLICT`            | An outstanding settlement deposit must be paid first.                        | `{ outstandingAmount, currency }`               | Correct the request or blocking state, then submit a new operation. |
    | `WITHDRAWAL_ADDRESS_CURRENCY_MISMATCH` |    422 | `VALIDATION_ERROR`    | The request currency differs from the destination account currency.          | `{ expected, received }`                        | Correct the request or blocking state, then submit a new operation. |
    | `WITHDRAWAL_ADDRESS_NOT_FOUND`         |    404 | `NOT_FOUND`           | The destination account identifier is unknown or inaccessible to the caller. | `{ accountId }`                                 | Do not retry automatically; correct access or surface the error.    |
  </Tab>

  <Tab title="Withdrawal accounts">
    | Code                                      | Status | Type                  | Trigger                                                                                 | Details                 | Client action                                                       |
    | ----------------------------------------- | -----: | --------------------- | --------------------------------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------- |
    | `WITHDRAWAL_ACCOUNT_NOT_LIVE`             |    403 | `AUTHORIZATION_ERROR` | The destination withdrawal account is not active.                                       | `{ accountId, status }` | Do not retry automatically; correct access or surface the error.    |
    | `WITHDRAWAL_ACCOUNT_FIAT_INVALID_NETWORK` |    400 | `VALIDATION_ERROR`    | The `network` filter was supplied together with `assetType=FIAT`, which has no network. | `{ network }`           | Correct the request or blocking state, then submit a new operation. |
  </Tab>

  <Tab title="Exposures">
    | Code                   | Status | Type                  | Trigger                                                      | Details | Client action                                                    |
    | ---------------------- | -----: | --------------------- | ------------------------------------------------------------ | ------- | ---------------------------------------------------------------- |
    | `EXPOSURE_NOT_ENABLED` |    403 | `AUTHORIZATION_ERROR` | No credit exposure limit is configured for the organization. | `{}`    | Do not retry automatically; correct access or surface the error. |
  </Tab>

  <Tab title="Platform">
    | Code                  | Status | Type                  | Trigger                                                    | Details           | Client action                                                    |
    | --------------------- | -----: | --------------------- | ---------------------------------------------------------- | ----------------- | ---------------------------------------------------------------- |
    | `FEATURE_NOT_ENABLED` |    403 | `AUTHORIZATION_ERROR` | The caller's organization is not enabled for this feature. | `{}`              | Do not retry automatically; correct access or surface the error. |
    | `INTERNAL_ERROR`      |    500 | `INTERNAL_ERROR`      | An unclassified server-side failure occurs.                | `{ resourceId? }` | Read the resource state before deciding whether to retry.        |
  </Tab>
</Tabs>

## Handling errors: the catch-and-branch pattern

Handle known codes that need bespoke behavior first (quote refresh, structured balance details), then fall back to `error.retryStrategy` for everything else — it sorts any current or future code into the right bucket without a client-side code list to maintain. Always capture `X-Trace-Id` before parsing the body, so you have it even when the body is malformed.

```javascript theme={null}
// Sign on every attempt — do not capture one. Two reasons:
//   1. JWT + signature freshness. JWTs have a 60s TTL and the X-Request-Signature
//      is bound to the JWT's nonce. A captured `Authorization` or `X-Request-Signature`
//      goes stale across any rate-limit wait that approaches the TTL; the next
//      attempt fails with `401 AUTH_TOKEN_EXPIRED`, or with `401 AUTH_TOKEN_INVALID`
//      (`details.reason: "SIGNATURE_INVALID"`) inside what looks like a rate-limit
//      retry. The signed nonce being replayed against Valkey would also surface as
//      `401 AUTH_TOKEN_INVALID` (`details.reason: "REPLAYED"`).
//   2. Body reuse. Request bodies are consumed by the first fetch(), so retrying
//      with the same Request object throws `TypeError: Body has already been read`.
//
// The caller passes `method`, `body`, an `extraHeaders` factory for things like
// `Idempotency-Key` that must stay stable across attempts, and `signRequest`.
// Each recursive retry signs fresh and rebuilds the `init`. See Rate limiting →
// Handling rate limits pattern (canonical reference):
// https://docs.openfx.com/v3/rate-limiting#handling-rate-limits-pattern
async function call(url, method, body, extraHeaders, signRequest) {
  const signed = signRequest({ method, url, body });
  const res = await fetch(url, {
    method,
    headers: { ...signed.headers, ...extraHeaders },
    body: signed.body,
  });
  const traceId = res.headers.get("x-trace-id"); // Capture FIRST: always present
  console.log({ url, trace_id: traceId, status: res.status });

  if (res.ok) return res.json();

  const { error } = await res.json();

  switch (error.code) {
    // Bespoke: quote stale, get a fresh one instead of a blind retry
    case "QUOTE_EXPIRED": {
      return refreshQuoteAndRetry(url, method, body, extraHeaders, signRequest);
    }

    // Bespoke: surface the structured balance shortfall to the user
    case "WITHDRAWAL_INSUFFICIENT_BALANCE": {
      const { requiredAmount, availableAmount, currency } = error.details;
      throw new Error(
        `${error.code}: required ${requiredAmount} ${currency}, available ${availableAmount} ${currency} (trace ${traceId})`,
      );
    }
  }

  // No code-specific override matched above — fall back to the
  // server-declared retryStrategy. This is where every future code lands
  // automatically, with no client-side code list to maintain.
  switch (error.retryStrategy) {
    // Retry immediately with the same Idempotency-Key.
    case "RETRY_SAME_KEY": {
      return call(url, method, body, extraHeaders, signRequest); // next attempt: fresh JWT + signature
    }

    // Wait, then retry with the same key. Transient backpressure — e.g.
    // RATE_LIMIT_EXCEEDED, IDEMPOTENCY_IN_FLIGHT, IDEMPOTENCY_UNAVAILABLE.
    // Retry-After (seconds, not an epoch timestamp) is present on
    // RATE_LIMIT_EXCEEDED and IDEMPOTENCY_IN_FLIGHT, but NOT on
    // IDEMPOTENCY_UNAVAILABLE — fall back to a fixed short wait when it's
    // absent. The wait can exceed the 60s JWT TTL — which is why `call`
    // signs per attempt.
    case "WAIT_THEN_RETRY_SAME_KEY": {
      const retryAfterHeader = res.headers.get("retry-after");
      const retryAfter =
        retryAfterHeader === null ? NaN : Number(retryAfterHeader);
      const waitMs = Number.isFinite(retryAfter) ? retryAfter * 1000 : 5000; // 5s fallback when Retry-After is absent
      await sleep(waitMs);
      return call(url, method, body, extraHeaders, signRequest); // next attempt: fresh JWT + signature
    }

    // FIX_AND_RESUBMIT, CHECK_STATE_THEN_RETRY, TERMINAL, and any future
    // unrecognized value: bubble up. The trace ID is always present for support.
    case "FIX_AND_RESUBMIT":
    case "CHECK_STATE_THEN_RETRY":
    case "TERMINAL":
    default:
      throw new Error(
        `API error: ${error.code} (${error.retryStrategy}) — ${error.message} (trace ${traceId})`,
      );
  }
}

// Usage:
//   const idempotencyKey = randomUUID();  // stable per logical operation
//   const body = JSON.stringify(payload);
//   await call(
//     'https://api.openfx.com/v3/fx/trades',
//     'POST',
//     body,
//     { 'Idempotency-Key': idempotencyKey },     // stable across attempts; merged after signed headers
//     signRequest,
//   );
```

<Tip>
  **Don't retry blindly on 5xx.** When `error.retryStrategy` is
  `CHECK_STATE_THEN_RETRY` (e.g. `TRADE_EXECUTION_FAILED`,
  `WITHDRAWAL_INITIATION_FAILED`, `INTERNAL_ERROR`) on a write endpoint, GET the
  resource by `details.resourceId` before deciding to retry — see [the rule
  below](#retrying-trade-execution-failed). When in doubt, fetch the resource by
  ID before retrying. See [Idempotency → Crash
  recovery](/v3/idempotency#crash-recovery).
</Tip>

<div id="retrying-trade-execution-failed" />

### Retrying trade execution failed

On `TRADE_EXECUTION_FAILED` (500), the original request **may** have EXECUTED before the failure surfaced. There is exactly one safe procedure:

1. **Call `GET /v3/fx/trades/{id}` with the resource ID returned by the original POST.** If the trade exists, it EXECUTED; if you get `404 TRADE_NOT_FOUND`, it didn't.
2. **If the trade exists:** treat the operation as successful. Do not retry.
3. **If the trade doesn't exist:** retry the original `POST /v3/fx/trades` with the **same** `Idempotency-Key`. The 24-hour TTL means the server still knows the key, so a duplicate cannot land. If the original quote has since expired, request a fresh quote first and use a new idempotency key for the new trade attempt.

The same rule applies to `WITHDRAWAL_INITIATION_FAILED` (call `GET /v3/fx/withdrawals/{id}` first) and to any other 5xx on a write endpoint.

## Common mistakes

* **Switching on `message` instead of `code`.** `message` is human-readable and may be rephrased between releases. Only `code` is stable. The trade-up: lock your switch statement to codes the day you ship.
* **Forgetting to read `X-Trace-Id` until something breaks.** Capture it on every response (success too) and store it in the same log line as the request. Asking support to find a request without a trace ID adds hours to triage.
* **Assuming HTTP status maps cleanly to `error.type`.** Most do, but `IDEMPOTENCY_MISMATCH` returns **422** with `type: "CONFLICT"`. Always branch on `error.code` for behavior; use `type` only for categorisation.
* **Rebuilding a code-pattern list to decide retry behavior.** `error.retryStrategy` already carries this — read it directly instead of maintaining your own mapping of codes to retry buckets. It also covers codes added after you shipped.
* **Treating `WITHDRAWAL_INSUFFICIENT_BALANCE` as retryable.** It's a 409 (well-formed request, state-driven failure). Retrying without resolving the underlying balance hits the same error every time. Surface it to the user.
* **Retrying a 500 trade failure with a fresh `Idempotency-Key`.** A new key on `TRADE_EXECUTION_FAILED` risks executing the trade twice. The correct flow is the state-check-then-retry rule above: call `GET /v3/fx/trades/{id}` first; if the trade isn't there, retry the original POST with the **same** key (still in-TTL for 24 h). See [Idempotency](/v3/idempotency).

## Notable v2 → v3 changes

* Missing idempotency key returns **`428 IDEMPOTENCY_KEY_MISSING`**, not v2's `400 IDEMPOTENCY_KEY_REQUIRED` (see [v2 migration step 5](/v2/migration-v1-to-v2#step-5-handle-new-error-codes)). The other v2 idempotency codes carry over with the same statuses: `409 IDEMPOTENCY_IN_FLIGHT`, `422 IDEMPOTENCY_MISMATCH`.
* `*_NOT_FOUND` returns **404**, not 400 (v2 returned 400)
* `WITHDRAWAL_INSUFFICIENT_BALANCE` is **409 conflict**, not 400 (well-formed request, current state prevents it)
* **Read-path 500s collapse into `INTERNAL_ERROR`.** v2-style per-resource codes like `TRADE_FETCH_FAILED`, `QUOTE_FETCH_FAILED`, `WITHDRAWAL_FETCH_FAILED`, `DEPOSIT_LIST_FAILED`, `BALANCE_FETCH_FAILED`, and the inconsistent v2 `MARKET_FETCH_FAILED` all surface as `500 INTERNAL_ERROR` in v3. Write-path 500s keep their domain codes (`TRADE_EXECUTION_FAILED`, `WITHDRAWAL_INITIATION_FAILED`) because those carry `details.resourceId` for the check-state-then-retry pattern.
* **Only cataloged codes are part of the contract.** The example does not define separate 502, 504, or 451 error codes. Documented domain failures use their cataloged codes; other server failures use `500 INTERNAL_ERROR`.
* **Withdrawal-account list filters exist** (`?assetType`, `?network`, `?currency`, `?status`, `?verified`). Malformed filter values return the generic `400 VALIDATION_QUERY_FAILED` — v2's per-field filter-error codes (`WALLET_FILTER_INVALID_COIN`, `WALLET_FILTER_INVALID_NETWORK`) have no v3 successor. The v2 list-failure codes `WALLET_LIST_FAILED` / `WALLET_FIAT_LIST_FAILED` are absorbed into `INTERNAL_ERROR` along with the other read-path 500s.
* Validation errors carry `details.issues[]` (the Zod issue array), not a single message
* **`error.type` is mandatory.** v2's hybrid envelope only guaranteed a `message`; v3 guarantees `code`, `type`, `message`, `retryStrategy`, and `details` on every error response (`details` is `{}` when empty). Clients migrating from v2 can switch on `type` for coarse routing without defensive null-checks.
* **`error.retryStrategy` has no v2 equivalent.** v2 clients had to infer retry behavior from `code` and HTTP status. v3 adds a direct, machine-readable recovery hint — see [Triage matrix](#triage-matrix).

See [Migration from v2: HTTP status changes](/v3/migration-from-v2#http-status-changes) for the full delta.

## What's next

<CardGroup cols={2}>
  <Card title="Idempotency" icon="repeat" href="/v3/idempotency#how-it-works">
    Understand same-key, different-body collisions and the in-flight rule.
  </Card>

  <Card title="Rate limiting" icon="gauge-high" href="/v3/rate-limiting#handling-rate-limits-pattern">
    The back-off pattern for `RATE_LIMIT_EXCEEDED`.
  </Card>

  <Card title="Metadata & tracing" icon="fingerprint" href="/v3/metadata-and-tracing#filing-a-support-ticket">
    Capture `X-Trace-Id` on every response for fast triage.
  </Card>

  <Card title="Migration from v2" icon="arrow-right-arrow-left" href="/v3/migration-from-v2#http-status-changes">
    Full delta of HTTP status changes from v2.
  </Card>
</CardGroup>
