What changed vs v2
Biggest breaking changes
Tier 1 Retry-code showstopper (silent corruption risk)
The change below is the one called out in the opening Warning. It doesn’t fail loudly — it lets your working v2 code run while quietly producing the wrong outcome (a hard-broken quote-create call). Address it first.Quote create request schema is hard-broken
Quote create request schema is hard-broken
referencedUnit + referencedAmount indirection to a oneOf between buyAmount and sellAmount. v2 clients must rewrite the request site — there’s no field-rename shim.buyAmount or sellAmount — the server computes the other side at the quoted rate. Sending both or neither returns 400 VALIDATION_BODY_FAILED. v2’s 422 QUOTE_INVALID_REFERENCE_CURRENCY is removed — the oneOf constraint replaces it. See Field renames: Quote for the full delta.Tier 2 Breaking API-shape changes (loud failures, every client touches)
These break loudly — your client either gets a clear 4xx with a preciseerror.code or a deserialization error on the first run. Painful but tractable. Every v2 client touches most of these.
Success-response envelope removed
Success-response envelope removed
{ status, data, message }. v3 wraps a single resource in { data: <resource> } (no status, no message), or a { data, pagination: { limit, hasNext, nextCursor, hasPrev, prevCursor } } envelope for paginated collections. POST /withdrawals nests an optional metadata object inside the resource at data.metadata; no other endpoint returns metadata.response.status === "success" or response.data.trade breaks. Clients that already unwrap one data layer (res.data) need to keep that unwrap — v3 still nests the resource one level under data, it just drops status and message.URL paths
URL paths
- Drop
{orgId}everywhere. It’s derived from the API key. brokerage→fx- Action names → resource nouns
Amounts are strings
Amounts are strings
int64) need to switch to string parsing. See Amounts.Pagination is cursor-based
Pagination is cursor-based
Trade response no longer bundles balances
Trade response no longer bundles balances
balances, add a GET /v3/fx/balances call after the trade.Errors are envelope-shaped with machine-readable codes
Errors are envelope-shaped with machine-readable codes
error.code, not message or top-level status. All five envelope fields — code, type, message, retryStrategy, and details — are required on every v3 error (details is {} when a code carries no structured detail). Coarse routing on error.type (e.g. VALIDATION_ERROR vs CONFLICT vs RATE_LIMITED) is safe without null-checks; retryStrategy tells you directly whether to retry and how (see Errors → Triage matrix). There is no requestId in the body — correlate via the X-Trace-Id response header. See Errors.Fiat + stablecoin withdrawals merged
Fiat + stablecoin withdrawals merged
GET /v3/fx/withdrawals and GET /v3/fx/withdrawals/{id} return withdrawalAccountId (a reference to GET /v3/fx/withdrawal-accounts) rather than embedding the destination account inline — fetch the account separately if you need its details.Quote single-use: second trade returns 409 QUOTE_ALREADY_CONSUMED
Quote single-use: second trade returns 409 QUOTE_ALREADY_CONSUMED
status: "CONSUMED" the moment a trade executes against it; any second POST /v3/fx/trades with the same quoteId returns 409 QUOTE_ALREADY_CONSUMED, even within the original TTL. Re-quote between every trade.Quote.status enum (ACTIVE | EXPIRED | CONSUMED, visible via GET /v3/fx/quotes/{id}) lets you check state before retrying. See Field renames: Quote.Balance shape: flat map → array of Balance resources
Balance shape: flat map → array of Balance resources
{currency: amount} map of numbers. v3 returns an array of typed Balance records with availableBalance and totalBalance per currency (string-encoded).availableBalance ≤ totalBalance. The held (earmarked) portion is the difference totalBalance − availableBalance; it is not broken out as a separate field in v3. Code that does balances["USD"] needs to iterate or index the array instead.Status enum casing: uppercase canonical
Status enum casing: uppercase canonical
executed, pending); v3 returns all statuses UPPERCASE as the canonical form (e.g. EXECUTED, COMPLETED). Deposit’s status is a closed enum: PENDING, COMPLETED, ERROR. Case-sensitive comparisons (if (trade.status === "executed")) break silently — coerce or compare uppercase.Unknown request-body fields are now rejected (400 VALIDATION_BODY_FAILED)
Unknown request-body fields are now rejected (400 VALIDATION_BODY_FAILED)
400 VALIDATION_BODY_FAILED and details.issues[] describing the unexpected field. This catches three classes of bug that used to fail silently:- Stale v2 request-body field names left in request builders. The actual v2 request bodies were small:
POST /generate_quotecarriedamount/buy/sell/referencedUnit/quoteForSeconds;POST /tradecarried onlyquoteId; bothPOST /withdrawalandPOST /fiat_withdrawalcarriedamount/currency/withdrawalAddressId.quoteIdcarries over unchanged; the rest (amount,buy/sell,referencedUnit,withdrawalAddressId) are renamed or not accepted by the v3 schemas —buy/sellbecomebuyCurrency/sellCurrency, and on the withdrawal body, v2amountbecomeswithdrawalAmount. See the rename tables below. - Typos in v3 field names (e.g.
sellAmoutinstead ofsellAmount,withdrawlAmountinstead ofwithdrawalAmount,quoteIddinstead ofquoteId). - Speculative forward-compatibility — sending a field your integration thinks v4 will support, or a server-populated response-side field (
transactionHash,withdrawalId) confused for a request-body field. The server tells you instead of pretending the request was honored.
POST /v3/fx/quotes, POST /v3/fx/trades, POST /v3/fx/withdrawals. See the “Schema enforcement: unknown fields rejected” section below for negative-example bodies per endpoint.Tier 3 Lower-risk shifts
Mostly housekeeping — easy to spot, easy to fix. The HTTP status-code refinements are the biggest item; the rest is additive or non-breaking.HTTP status code refinements (400 → 404, 409, 422, 428)
HTTP status code refinements (400 → 404, 409, 422, 428)
if (status === 400) {showFieldError()}, switch on error.code instead.Idempotency-Key missing-header response: 400 → 428
Idempotency-Key missing-header response: 400 → 428
Idempotency-Key is required on writes in both v2 and v3 — sending a write
without it has always failed. The change is the missing-header response
status: v2 returns 400 IDEMPOTENCY_KEY_REQUIRED (schema-level validation
framing); v3 returns 428 IDEMPOTENCY_KEY_MISSING (RFC 6585 Precondition
Required — more semantically precise, since the header is a precondition
rather than a body-schema field). If your v2 client already generates an
Idempotency-Key per logical operation (which it must, to avoid the v2
400), the only update is the new status code on the still-failing path.Idempotency-Replayed response header — new in v3
Idempotency-Replayed response header — new in v3
Idempotency-Replayed: true | false response header on every
response from POST /v3/fx/quotes, POST /v3/fx/trades, and POST /v3/fx/withdrawals. true on a cache hit (the response came from the
idempotency cache); false on a fresh execution. Always present on these
three endpoints; omitted on GETs. Useful for audit, reconciliation, and
silently-replayed-without-knowing diagnostics. Customer code that didn’t
read this header in v2 doesn’t have to start reading it in v3 — it’s purely
additive — but reconciliation tooling that hashes response bodies across
retries should branch on it.Resource IDs are now readable, typed-prefix strings (bare UUIDs still accepted on input)
Resource IDs are now readable, typed-prefix strings (bare UUIDs still accepted on input)
tde_5W7guYdHT24JFnRQrZN9y8) — instead of v2’s bare UUID v4. For backward
compatibility, v3 still accepts a bare UUID v4 wherever an ID is
expected on input; any v2 UUID you’ve persisted continues to resolve
unchanged, so no migration of stored IDs is required. New integrations
should use the readable form the API returns. See Resource
IDs.Client-side correlation is now X-Request-Id (replaces the Request-Id header)
Client-side correlation is now X-Request-Id (replaces the Request-Id header)
X-Request-Id: optional, and echoed
back verbatim on the response only when you supply it. This replaces v2’s
Request-Id header. It’s purely additive; clients that don’t send it are
unaffected. Separately, POST /v3/fx/withdrawals accepts an optional,
client-owned metadata object in the request body for bookkeeping that
persists with the resource — the server returns it nested inside the
resource at data.metadata on success. metadata is not accepted on any
other write endpoint. See Metadata &
tracing.Endpoint map
Every v2 endpoint and its v3 replacement.amount → buyAmount / sellAmount), semantic renames (transactedAt →
createdAt, withdrawalAddressId → withdrawalAccountId, buy/sell →
buyCurrency/sellCurrency on Quote/Trade/Pair), and removals. Rows where
the v2 and v3 names match are unchanged.Field renames: Trade
Field renames: Quote
v2 → v3 quote-create request, side-by-side:422 QUOTE_INVALID_REFERENCE_CURRENCY when referencedUnit didn’t equal buy or sell. That error code is removed in v3 — the oneOf constraint replaces it. Sending both buyAmount and sellAmount, or neither, returns 400 VALIDATION_BODY_FAILED with details.issues describing the schema violation.
Field renames: Withdrawal
Field renames: Balance
v2 returned a flat{currency: amount} map. v3 returns an array of Balance resources.
Field renames: Deposit
Field renames: Pair (was Market in v2)
GET /v3/fx/pairs also nests the list one level deeper than v2’s flat array: the response is { "data": { "pairs": [ ... ] } }, not { "data": [ ... ] }.
Field renames: WithdrawalAccount (was two schemas in v2)
v2 had two separate list endpoints with two separate response shapes:fiat_withdrawal_addresses (bank accounts) and withdrawal_addresses (stablecoin wallets). v3 merges them into a single WithdrawalAccount schema served by GET /v3/fx/withdrawal-accounts, with rail-specific destination fields (holder name, bank name, account number, routing number, transfer type, on-chain address) nested under a single destination object instead of spread across top-level fields.
- UIs that displayed bank routing / SWIFT / transfer type from the list response need to read them from
destination.*instead of the top level. - UIs that built a label by concatenating
accountName+accountNumberfor fiat ORcoinName+coinNetworkName+addressfor stablecoin can now read the customer-setdisplayNamedirectly, or compose their own label fromdisplayName+currency+rail. - The merged list supports server-side filters —
?assetType,?verified,?status,?currency,?network— so you no longer need to split client-side bytype(CRYPTO/FIAT) unless your UI needs a view the filters don’t cover. Cursor pagination (?limit/?startingAfter/?endingBefore) is supported. - Error code remap: v2’s read-path 500s
WALLET_LIST_FAILED/WALLET_FIAT_LIST_FAILEDcollapse into v3’s catch-allINTERNAL_ERROR(500) — read-path 500s no longer carry domain-specific codes. v2’s filter-validation codes (WALLET_FILTER_INVALID_COIN/WALLET_FILTER_INVALID_NETWORK) have no same-named v3 successor: an unrecognized filter value (e.g. an unknown?network) returns the generic400 VALIDATION_QUERY_FAILED. Combining?networkwith?assetType=FIATis a distinct case with its own dedicated code —400 WITHDRAWAL_ACCOUNT_FIAT_INVALID_NETWORK— not the generic validation code. See Errors.
Resource IDs: readable, typed-prefix strings (bare UUIDs still accepted on input)
v3 responses return a readable ID — a lowercase 3-to-5-letter resource-type prefix plus a Base58 encoding of the underlying UUID (e.g.tde_5W7guYdHT24JFnRQrZN9y8) — instead of v2’s bare UUID v4. For backward compatibility, v3 still accepts a bare UUID v4 wherever an ID is expected on input, so no migration of stored IDs is required: any v2 UUID you’ve persisted continues to resolve in v3 endpoints unchanged. New integrations should use the readable form the API returns.
See Resource IDs for the prefix table, regex, and stability contract.
HTTP status changes
v3 refines HTTP status codes to be more semantically accurate. Catch-by-status code paths from v2 need to be updated.Error contract changes
The example contract includes only the codes in its error catalog. It does not define separate 502, 504, or 451 codes. Documented domain failures use their cataloged codes; other server failures use500 INTERNAL_ERROR.
There is no requestId body field — correlate via the X-Trace-Id response header. See Errors for the full envelope spec.
Estimate the migration
Use this table to size the work before you start. The two-grep triage in the Tip at the top of the page (response.data\|response.status, \.amount\b) covers most of Tier 2 — count your hits there to refine the estimate.
Cutover strategy
A phased approach that scales from a single-endpoint integration to a full v2 surface. The base host (api.openfx.com), API key, and Idempotency-Key generation logic are identical between v2 and v3 — so you can run both side-by-side and flip one call site at a time. The auth flow changes at the call site (60s JWT + X-Request-Signature, see the Warning at the top), but the same EC private key signs both v2 and v3, so no new credential is issued.
Run v2 and v3 side-by-side behind a feature flag
Cut over read-only endpoints first
/pairs, /balances, list endpoints. Failures here are loud and contained
— bad routing, missed pagination, balance-shape parsing — and don’t risk
duplicate writes. This is also where you shake out the new response
envelope, amount-string parsing, and cursor pagination once for the whole
client.Cut over quote → trade
oneOf between buyAmount and
sellAmount), single-use behavior (409 QUOTE_ALREADY_CONSUMED on a second
trade against the same quoteId), and 409 QUOTE_EXPIRED after
expiresAt. Confirm Idempotency-Key is generated once per logical
operation, not once per HTTP attempt — quoteKey for the quote,
tradeKey for the trade.Cut over withdrawals last
POST /v3/fx/withdrawals and
switch from walletAddress / fiat_withdrawal_addresses[id] to a single
withdrawalAccountId. The 7-day idempotency TTL carries forward from v2
unchanged, so existing retry-window logic does not need to change — same-key
retry for up to 7 days replays; outside that window the same key creates a
fresh withdrawal (the v2 behavior, preserved).Update webhook handlers to the v3 envelope
type
(deposits/withdrawals) plus a dotted eventType (deposit.completed,
withdrawal.processing, …), a top-level data object, camelCase keys, and
string amounts — the same conventions as the API, so one set of types
covers both surfaces. Signatures move to the X-OpenFX-Signature header
(HMAC-SHA256). Update your handlers and re-point signature verification. See
Webhooks.api.openfx.com); the only switch when you promote to Live is swapping your sandbox_-prefixed key for the unprefixed Live one — same as v2. See Environments.
Sandbox checklist before Live cutover
Exercise these cases in Sandbox before flipping a call site in Live. They cover every Tier 1 and Tier 2 break.- Quote → trade round-trip. Verify amount string parsing on the response, and that
quoteAmountis the counter-leg of whichever amount you supplied. - Quote single-use. Trade the same
quoteIdtwice. Confirm the second call returns409 QUOTE_ALREADY_CONSUMED. - Quote expiry. Wait past
expiresAtthen trade. Confirm409 QUOTE_EXPIRED. - Cursor pagination. List 100+ trades with
limit=25and walk forward withstartingAfter, passing the opaquepagination.nextCursorback unchanged each request. Confirmpagination.nextCursorisnullon the last page. - Error shape. Trigger
WITHDRAWAL_INSUFFICIENT_BALANCE(over-withdraw),VALIDATION_BODY_FAILED(send an extra body field), andIDEMPOTENCY_KEY_MISSING(omit the header on a write). Confirm your error router branches onerror.codeanderror.type. - Idempotency replay. Re-send a write inside the TTL with the same
Idempotency-Key. Confirm the response body is byte-identical andIdempotency-Replayed: trueis set. - Withdrawal idempotency replay. Re-fire a withdrawal within the 7-day TTL with the same
Idempotency-Key. ConfirmIdempotency-Replayed: trueis set and the response body matches the original byte-for-byte. - Webhook payload. Trigger a webhook from a v3-created trade. Confirm your handler parses the redesigned envelope — a resource-noun
typeplus a dottedeventType, adataobject, camelCase keys, and string amounts — and verifies theX-OpenFX-Signatureheader.
First 48 hours post-cutover
What to watch as v3 traffic ramps in Live. A clean cutover shows up in metrics; a stale request builder shows up inVALIDATION_BODY_FAILED spikes.
- Error-code distribution by
error.code, not just by HTTP status. A spike inVALIDATION_BODY_FAILEDusually means a request builder still carries a v2 field name or a typo slipped through. A spike inQUOTE_ALREADY_CONSUMEDmeans a retry loop is hitting the same quote twice. - Duplicate-operation rate. Compare unique
Idempotency-Keycounts vs operation counts per endpoint. Divergence on any write endpoint points at a client that’s regenerating its key per attempt instead of carrying it across retries — same root-cause as in v2. - Latency. p50 / p95 against your v2 baseline. Cursor pagination changes loop shape — watch for accidental tight loops where the client never reaches
pagination.nextCursor === null(e.g. checking the wrong field, treatingnullas “missing” and retrying, decoding the opaque cursor and getting it wrong). - Webhook delivery + verification rate. After cutting handlers to the redesigned envelope, watch for a drop in successfully-verified deliveries — a stale v2 parser or an unmigrated
X-OpenFX-Signaturecheck shows up here. Idempotency-Replayed: truerate on writes. Sanity that your retry layer is deduping inside the TTL. Zero replays during a network-flaky window probably means your client is regenerating the key per attempt.
Escalating to support
When something doesn’t add up, send all of this in the first message — it routes faster than a back-and-forth.X-Trace-Idfrom the response headers. Present on every response, success or failure.error.code,error.type, anderror.detailsas a JSON paste, not a paraphrased message string.Idempotency-Keyif the call was a write.- For indeterminate states (
TRADE_EXECUTION_FAILED/WITHDRAWAL_INITIATION_FAILED, both 500): the result of the follow-upGET /v3/fx/{resource}/{id}against thedetails.resource_idso support can correlate against the persisted record. - Approximate timestamp (UTC) of the failing call, to within a minute. The trace ID resolves uniquely but the surrounding window helps reconstruct retry sequences.
What you can keep
- Your existing API key (same JWT/ES256 flow)
- Your existing
Idempotency-Keygeneration logic (required on writes in both v2 and v3; v3 only changes the missing-header response from400 IDEMPOTENCY_KEY_REQUIREDto428 IDEMPOTENCY_KEY_MISSING) - Your EC signing key — the same private key mints the v3 JWT and the
X-Request-Signature. (Webhook handlers, however, must be updated — see below.)
X-OpenFX-Signature)
need updating. See Webhooks for the redesigned
envelope.Schema enforcement: unknown fields rejected
v3 request schemas carry an OpenAPI guard that rejects any field not declared in the schema —additionalProperties: false on flat objects (TradeCreateRequest, WithdrawalCreateRequest) and unevaluatedProperties: false on the composed QuoteCreateRequest (which discriminates by required key under oneOf). The server returns 400 VALIDATION_BODY_FAILED with details.issues[] describing the unexpected field. v2 silently ignored unknown fields; v3 surfaces them as bugs.
The same 400 VALIDATION_BODY_FAILED code is reused for other body-schema failures (wrong type, missing required field, oneOf violation); read details.issues[] to distinguish.
POST /v3/fx/quotes — QuoteCreateRequest
Three failure modes, all returning 400 VALIDATION_BODY_FAILED:
oneOf exactly-one rule that unevaluatedProperties: false was specifically chosen to coexist with. details.issues[] describes the oneOf violation rather than an unknown-key error.
POST /v3/fx/trades — TradeCreateRequest
The body legally carries only quoteId. Any other key is rejected:
POST /v3/fx/withdrawals — WithdrawalCreateRequest
Legal fields are withdrawalAccountId, withdrawalAmount, currency. Everything else is rejected:
transactionHash is a response-only, server-populated field — it is never accepted on the request body — so sending it returns the same 400 VALIDATION_BODY_FAILED as any unknown field. The same applies to a v2-style reference field: it is not part of WithdrawalCreateRequest in v3.
Common mistakes during migration
- Forgetting to remove the response unwrap. If you have a wrapper like
(res) => res.data.data, every call site silently breaks. v3 returns the resource bare; for paginated lists, the response is{ data, pagination }sores.data.datahappens to still work for the array butres.data.paginationis what carries the cursors, andhas_moreis gone entirely. Strip the wrapper centrally, not per call site. - Sending amounts as numbers from a
Decimallibrary. Most serializers default to JSON numbers. Call.toString()explicitly. See Amounts: common mistakes. - Catching errors by HTTP status alone. v3 separates
code,type, andstatus. Branch onerror.codefor behavior; status mapping has shifted (*_NOT_FOUND400→404,INSUFFICIENT_BALANCE400→409). See Errors: catch-and-branch. - Updating the URL but not the request body shape. Quote create takes
buyCurrency/sellCurrency(renamed from v2’sbuy/sell) + exactly one ofbuyAmount/sellAmount. The v2amount+referencedUnitpair is gone — sending it fails the v3 schema with400 VALIDATION_BODY_FAILED. See the field-rename tables above and the Schema enforcement section. - Leaving stale v2 fields in request builders. v2 silently dropped unknown fields; v3 rejects them with
400 VALIDATION_BODY_FAILED. Audit your request-construction code for the v2 request-body fields that changed:withdrawalAddressId→withdrawalAccountIdandamount→withdrawalAmount(withdrawal), and the quote shape (amount+referencedUnit→ exactly one ofbuyAmount/sellAmount;buy/sell→buyCurrency/sellCurrency;quoteForSecondscarries over unchanged). Also watch for v2 response-side fields (transactionHash,withdrawalId) accidentally posted back to the server —transactionHashis response-only in v3, never a request field, so it’s rejected. One strayamount: 1000next to aquoteIdis enough to fail aPOST /v3/fx/tradescall. - Re-using v2 retry code that generates a fresh idempotency key. v2 also rejects writes without an
Idempotency-Key(400 IDEMPOTENCY_KEY_REQUIRED), so working v2 clients are already sending one — but some clients regenerate the key per attempt, which defeats the retry-safety mechanism (each “retry” creates a new operation, not a replay of the original). Generate once per logical operation and re-use across attempts. v3 keeps the same scoping rules — only the missing-header status changes (400 → 428). See Idempotency.
Frequently asked questions
Do my v2 UUIDs still work in v3?
Do my v2 UUIDs still work in v3?
tde_5W7guYdHT24JFnRQrZN9y8); use that form going forward.
See Resource IDs.Can I keep my existing API key?
Can I keep my existing API key?
X-Request-Signature header (ES256 over a canonical string). See
Authentication for the new minting flow and
per-language helpers. The environment switch when promoting to Live is the
same as v2 — swap your sandbox_-prefixed key for the unprefixed Live one;
see Environments.Why am I now getting 422 where I used to get 400?
Why am I now getting 422 where I used to get 400?
400) from
semantic violations (your JSON parses fine but breaks a business rule →
422). v2 collapsed both into 400. Full mapping in the HTTP status
changes table. Switch your error router from HTTP
status to error.code and the split goes away.My second trade against the same quoteId returned 409 — bug or feature?
My second trade against the same quoteId returned 409 — bug or feature?
status to CONSUMED, and any later trade against the same
quoteId returns 409 QUOTE_ALREADY_CONSUMED. Re-quote between every
trade. See the Tier 2 accordion above.Did webhook payloads change in v3?
Did webhook payloads change in v3?
type
(deposits/withdrawals) plus a dotted eventType (deposit.completed,
withdrawal.processing, …), a top-level data object, camelCase keys, and
string amounts. Signatures move to the X-OpenFX-Signature header
(HMAC-SHA256). Because the conventions now match the API, you can share one
set of types and one normalizer across both surfaces. See
Webhooks.Is there a v2 compat shim?
Is there a v2 compat shim?
What’s next
Quickstart
Errors
error.code.