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. You can safely rely on error.type for coarse routing (auth vs validation vs CONFLICT vs rate-limited vs internal) without null-checks. 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

A bare { "status": "error", "message": "..." } shape has three problems:
  1. No code. You’d 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.
The error envelope below fixes all three.

Error envelope fields

Errors are returned as application/json; the envelope above is the complete 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: 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 categorization.

HTTP status mapping

A duplicate JSON key in a request body returns 400 VALIDATION_BODY_FAILED. A rejected X-Request-Id returns 400 VALIDATION_HEADER_FAILED; its details.issues identifies the header. Response bodies use Content-Type: application/json without a charset parameter. type is the coarse routing taxonomy. HTTP status is the wire-level signal. They line up like this: VALIDATION_ERROR spans five statuses, not one. 400 and 422 carry most of the volume and are worth distinguishing: 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.” The remaining three are each single-purpose and don’t need their own flow: 428 (missing Idempotency-Key), 405 (method not allowed), and 413 (payload too large). 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.

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 _DISABLED codes. A _DISABLED code means access has been revoked or blocked; this catalog defines TRADE_DISABLED and WITHDRAWAL_DISABLED_FOR_ACCOUNT (both 403). The second names the account scope in its suffix. Resource-level toggles keep their own codes (TRADE_CURRENCY_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:
  • Handle an unknown error.type as INTERNAL_ERROR (treat it as a server-side category you don’t yet model).
  • Bubble up an unknown error.code with its X-Trace-Id so a newly-added code never silently breaks your client.
  • Handle an unknown error.retryStrategy 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 FX and Payments APIs define 107 distinct error codes; a code shared by both appears once, or once per surface when the surfaces define it differently. 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 categorization.
  • 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.
  • Retrying a 500 trade failure with a fresh Idempotency-Key. Risks executing the trade twice. See Retrying trade execution failed above for the correct procedure.

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.