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.Why this shape
A bare{ "status": "error", "message": "..." } shape has three problems:
- No code. You’d 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
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. 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 categorization.
HTTP status mapping
A duplicate JSON key in a request body returns400 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
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:
- Handle an unknown
error.typeas INTERNAL_ERROR (treat it as a server-side category you don’t yet model). - Bubble up an unknown
error.codewith itsX-Trace-Idso a newly-added code never silently breaks your client. - Handle an unknown
error.retryStrategyas TERMINAL (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 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 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
- Accounts
- Capabilities
- Payout addresses
- Payout quotes
- Payouts
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
On TRADE_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 get404TRADE_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.
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, but IDEMPOTENCY_MISMATCH returns 422 withtype: "CONFLICT". Always branch onerror.codefor behavior; usetypeonly for categorization. - 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_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.