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.Why this shape
v2 errors look like{ "status": "error", "message": "Insufficient balance" }. That has three problems:
- No code. Clients have to string-match
message, which breaks the moment a copy edit lands. - No trace ID. Triage starts with “send us your logs.”
- No structure. “Insufficient balance” tells you what’s wrong, but not by how much, in which currency.
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. Readingerror.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
Treaterror.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.typeshould be handled asINTERNAL_ERROR(treat it as a server-side category you don’t yet model). - An unknown
error.codeshould bubble up with itsX-Trace-Idso a newly-added code never silently breaks your client. - An unknown
error.retryStrategyshould be handled asTERMINAL(the safest default — don’t retry automatically).
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 extensionx-openfx-error-catalog is the source of truth for status, client action, lifecycle, and the closed details schema.
- Authentication
- Validation
- Idempotency
- Routing
- Rate limiting
- Trading
- Quotes
- Deposits
- Withdrawals
- Withdrawal accounts
- Exposures
- Platform
Handling errors: the catch-and-branch pattern
Handle known codes that need bespoke behavior first (quote refresh, structured balance details), then fall back toerror.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.
Retrying trade execution failed
OnTRADE_EXECUTION_FAILED (500), the original request may have EXECUTED before the failure surfaced. There is exactly one safe procedure:
- Call
GET /v3/fx/trades/{id}with the resource ID returned by the original POST. If the trade exists, it EXECUTED; if you get404 TRADE_NOT_FOUND, it didn’t. - If the trade exists: treat the operation as successful. Do not retry.
- If the trade doesn’t exist: retry the original
POST /v3/fx/tradeswith the sameIdempotency-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.
WITHDRAWAL_INITIATION_FAILED (call GET /v3/fx/withdrawals/{id} first) and to any other 5xx on a write endpoint.
Common mistakes
- Switching on
messageinstead ofcode.messageis human-readable and may be rephrased between releases. Onlycodeis stable. The trade-up: lock your switch statement to codes the day you ship. - Forgetting to read
X-Trace-Iduntil 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, butIDEMPOTENCY_MISMATCHreturns 422 withtype: "CONFLICT". Always branch onerror.codefor behavior; usetypeonly for categorisation. - Rebuilding a code-pattern list to decide retry behavior.
error.retryStrategyalready 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_BALANCEas 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 onTRADE_EXECUTION_FAILEDrisks executing the trade twice. The correct flow is the state-check-then-retry rule above: callGET /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’s400 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_FOUNDreturns 404, not 400 (v2 returned 400)WITHDRAWAL_INSUFFICIENT_BALANCEis 409 conflict, not 400 (well-formed request, current state prevents it)- Read-path 500s collapse into
INTERNAL_ERROR. v2-style per-resource codes likeTRADE_FETCH_FAILED,QUOTE_FETCH_FAILED,WITHDRAWAL_FETCH_FAILED,DEPOSIT_LIST_FAILED,BALANCE_FETCH_FAILED, and the inconsistent v2MARKET_FETCH_FAILEDall surface as500 INTERNAL_ERRORin v3. Write-path 500s keep their domain codes (TRADE_EXECUTION_FAILED,WITHDRAWAL_INITIATION_FAILED) because those carrydetails.resourceIdfor 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 generic400 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 codesWALLET_LIST_FAILED/WALLET_FIAT_LIST_FAILEDare absorbed intoINTERNAL_ERRORalong with the other read-path 500s. - Validation errors carry
details.issues[](the Zod issue array), not a single message error.typeis mandatory. v2’s hybrid envelope only guaranteed amessage; v3 guaranteescode,type,message,retryStrategy, anddetailson every error response (detailsis{}when empty). Clients migrating from v2 can switch ontypefor coarse routing without defensive null-checks.error.retryStrategyhas no v2 equivalent. v2 clients had to infer retry behavior fromcodeand HTTP status. v3 adds a direct, machine-readable recovery hint — see Triage matrix.
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.