Skip to main content
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.
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.
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.

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

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.
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.

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: 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). The table below cross-references the same catalog by HTTP status, for clients that key off status first:

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) 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. 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: 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: 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.

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.
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. When in doubt, fetch the resource by ID before retrying. See Idempotency → Crash recovery.

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.

Notable v2 → v3 changes

  • Missing idempotency key returns 428 IDEMPOTENCY_KEY_MISSING, not v2’s 400 IDEMPOTENCY_KEY_REQUIRED (see v2 migration step 5). 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.
See Migration from v2: HTTP status changes for the full delta.

What’s next

Idempotency

Understand same-key, different-body collisions and the in-flight rule.

Rate limiting

The back-off pattern for RATE_LIMIT_EXCEEDED.

Metadata & tracing

Capture X-Trace-Id on every response for fast triage.

Migration from v2

Full delta of HTTP status changes from v2.