Skip to main content
If you have a working v2 integration and need to cut over, this is your map. Skim the biggest breaking changes first to size the work, then use the endpoint and field-rename tables as reference during the actual migration.
Read this before anything else — one request-shape change that breaks working code silently.Quote request schema is hard-broken. referencedUnit / referencedAmount are removed; the request now takes exactly one of buyAmount or sellAmount (oneOf), and the currency fields are renamed buyCurrency / sellCurrency. v2 client code that builds the quote-create body must be rewritten — there is no compat shim. Full detail in the “Quote create request shape” accordion below.This is the one item that corrupts working v2 code silently rather than failing loudly. The rest of this page covers additional breaking API-shape changes (every client touches these — loud failures) and lower-risk renames. Idempotency TTLs are unchanged from v2 (quote 30 min, trade 24 h, withdrawal 7 days), so retry code migrates with no TTL audit needed.
Auth contract changed — v2 requests will not authenticate against v3.Two changes compound:
  1. JWT max TTL drops 120s → 60s. A v2-style JWT minted with exp - iat = 120 returns 401 AUTH_TOKEN_INVALID_CONFIG.
  2. Every v3 request must carry X-Request-Signature. ES256 signature (IEEE P-1363 fixed 64-byte r‖s, base64url) over a canonical string that binds method, path, query, nonce, and SHA-256(body). A missing or bad signature is rejected with 401 AUTH_TOKEN_INVALID (with an optional details.reason such as SIGNATURE_INVALID). Nonce uniqueness is now server-enforced — a replayed nonce is likewise rejected with 401 AUTH_TOKEN_INVALID (details.reason: REPLAYED).
The same EC private key signs both the JWT and the request — no new credential issuance. But every call site that mints a JWT also needs a signRequest helper. Walk through Authentication before cutting any traffic.
Triage in five minutes. Run two greps against your v2 client: response.data\|response.status (envelope sites) and \.amount\b (amount sites). Those two patterns dominate the migration. Everything else is a smaller-radius change.

What changed vs v2

The rest of this page expands each of those rows into the field-level deltas, the endpoint map, and the breaking changes ranked by client-side cost.

Biggest breaking changes

breakingmigration
Three tiers, ranked by client-side migration cost — highest first. Tier 1 is the silent-corruption risk; Tier 2 breaks loudly; Tier 3 is housekeeping.

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.
The quote-create request body switched from v2’s referencedUnit + referencedAmount indirection to a oneOf between buyAmount and sellAmount. v2 clients must rewrite the request site — there’s no field-rename shim.
Supply exactly one of 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 precise error.code or a deserialization error on the first run. Painful but tractable. Every v2 client touches most of these.
This is the change with the largest blast radius. v2 wraps every success in { 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.
Every client that does 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.
  • Drop {orgId} everywhere. It’s derived from the API key.
  • brokeragefx
  • Action names → resource nouns
JSON parsers that strongly type numbers (Java, Kotlin, Swift, Go with int64) need to switch to string parsing. See Amounts.
The response shape changes from a flat array to an explicit envelope:
See Pagination for worked forward + backward iteration examples and the full opacity contract.
If your post-trade flow relied on the bundled balances, add a GET /v3/fx/balances call after the trade.
Switch on 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.
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.
v2 allowed multiple trades against a single quote. v3 marks a quote 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.
The Quote.status enum (ACTIVE | EXPIRED | CONSUMED, visible via GET /v3/fx/quotes/{id}) lets you check state before retrying. See Field renames: Quote.
v2 returned a single flat {currency: amount} map of numbers. v3 returns an array of typed Balance records with availableBalance and totalBalance per currency (string-encoded).
Invariant: 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.
v2 returned some statuses lowercase (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.
v2 silently dropped unknown fields. v3 rejects them at the OpenAPI schema layer with 400 VALIDATION_BODY_FAILED and details.issues[] describing the unexpected field. This catches three classes of bug that used to fail silently:
  1. Stale v2 request-body field names left in request builders. The actual v2 request bodies were small: POST /generate_quote carried amount / buy / sell / referencedUnit / quoteForSeconds; POST /trade carried only quoteId; both POST /withdrawal and POST /fiat_withdrawal carried amount / currency / withdrawalAddressId. quoteId carries over unchanged; the rest (amount, buy/sell, referencedUnit, withdrawalAddressId) are renamed or not accepted by the v3 schemas — buy/sell become buyCurrency/sellCurrency, and on the withdrawal body, v2 amount becomes withdrawalAmount. See the rename tables below.
  2. Typos in v3 field names (e.g. sellAmout instead of sellAmount, withdrawlAmount instead of withdrawalAmount, quoteIdd instead of quoteId).
  3. 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.
Applies to all three write endpoints — 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.
v3 separates schema violations (your JSON is malformed → 400) from semantic violations (your JSON is fine but breaks a business rule → 422), and pulls “not found” and “state conflict” out of the v2 400-bucket. Full mapping in HTTP status changes below. If your v2 client did if (status === 400) {showFieldError()}, switch on error.code instead.
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.
v3 adds an 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.
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; 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.
v3’s client-side correlation header is 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.
v2 and v3 both use camelCase JSON field names. Many fields are therefore identical across versions — the tables below list every field for completeness, marking the ones that actually change: structural splits (amountbuyAmount / sellAmount), semantic renames (transactedAtcreatedAt, withdrawalAddressIdwithdrawalAccountId, buy/sellbuyCurrency/sellCurrency on Quote/Trade/Pair), and removals. Rows where the v2 and v3 names match are unchanged.

Field renames: Trade

Field renames: Quote

Hard schema break. The quote-create request body switched from v2’s referencedUnit + referencedAmount indirection to a oneOf between buyAmount and sellAmount, and the currency fields renamed to buyCurrency / sellCurrency. v2 clients must rewrite the request site — there’s no field-rename shim. See the side-by-side below.
v2 → v3 quote-create request, side-by-side:
Validation behavior change: v2 returned 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. Practical impact on v2 client code:
  • 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 + accountNumber for fiat OR coinName + coinNetworkName + address for stablecoin can now read the customer-set displayName directly, or compose their own label from displayName + currency + rail.
  • The merged list supports server-side filters — ?assetType, ?verified, ?status, ?currency, ?network — so you no longer need to split client-side by type (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_FAILED collapse into v3’s catch-all INTERNAL_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 generic 400 VALIDATION_QUERY_FAILED. Combining ?network with ?assetType=FIAT is 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.
400 vs 422 split. v3 separates schema violations (your JSON is malformed → 400) from semantic violations (your JSON is fine but breaks a business rule → 422). v2 collapsed everything into 400. If your v2 client did if (status === 400) {showFieldError()}, you’ll miss the new 422 family; switch on the union or on error.code.
Write-path 500s are indeterminate. When TRADE_EXECUTION_FAILED or WITHDRAWAL_INITIATION_FAILED (both 500s) comes back, the operation may or may not have been recorded; OpenFX could not confirm the final state. Always GET the resource by ID using the details.resource_id echoed on the error envelope before retrying with the same Idempotency-Key. See Errors: Retrying TRADE_EXECUTION_FAILED.

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 use 500 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. The Tier 1 retry-code showstopper is flat-cost overhead on top of whichever row applies — budget half a day for the quote-create request rewrite, regardless of footprint.

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

Run v2 and v3 side-by-side behind a feature flag

Gate per-call-site cutover behind a feature flag so you can flip one resource at a time and roll back without redeploying. Don’t big-bang.
2

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

Cut over quote → trade

Verify the new quote request shape (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.
4

Cut over withdrawals last

Merge fiat and stablecoin call sites onto 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).
5

Update webhook handlers to the v3 envelope

v3 webhooks are redesigned: a resource-noun 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.
Sandbox and Live share the same base URL (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 quoteAmount is the counter-leg of whichever amount you supplied.
  • Quote single-use. Trade the same quoteId twice. Confirm the second call returns 409 QUOTE_ALREADY_CONSUMED.
  • Quote expiry. Wait past expiresAt then trade. Confirm 409 QUOTE_EXPIRED.
  • Cursor pagination. List 100+ trades with limit=25 and walk forward with startingAfter, passing the opaque pagination.nextCursor back unchanged each request. Confirm pagination.nextCursor is null on the last page.
  • Error shape. Trigger WITHDRAWAL_INSUFFICIENT_BALANCE (over-withdraw), VALIDATION_BODY_FAILED (send an extra body field), and IDEMPOTENCY_KEY_MISSING (omit the header on a write). Confirm your error router branches on error.code and error.type.
  • Idempotency replay. Re-send a write inside the TTL with the same Idempotency-Key. Confirm the response body is byte-identical and Idempotency-Replayed: true is set.
  • Withdrawal idempotency replay. Re-fire a withdrawal within the 7-day TTL with the same Idempotency-Key. Confirm Idempotency-Replayed: true is 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 type plus a dotted eventType, a data object, camelCase keys, and string amounts — and verifies the X-OpenFX-Signature header.

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 in VALIDATION_BODY_FAILED spikes.
  • Error-code distribution by error.code, not just by HTTP status. A spike in VALIDATION_BODY_FAILED usually means a request builder still carries a v2 field name or a typo slipped through. A spike in QUOTE_ALREADY_CONSUMED means a retry loop is hitting the same quote twice.
  • Duplicate-operation rate. Compare unique Idempotency-Key counts 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, treating null as “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-Signature check shows up here.
  • Idempotency-Replayed: true rate 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-Id from the response headers. Present on every response, success or failure.
  • error.code, error.type, and error.details as a JSON paste, not a paraphrased message string.
  • Idempotency-Key if the call was a write.
  • For indeterminate states (TRADE_EXECUTION_FAILED / WITHDRAWAL_INITIATION_FAILED, both 500): the result of the follow-up GET /v3/fx/{resource}/{id} against the details.resource_id so 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-Key generation logic (required on writes in both v2 and v3; v3 only changes the missing-header response from 400 IDEMPOTENCY_KEY_REQUIRED to 428 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.)
One convention across the API and webhooks. v3 webhooks are redesigned to use the same conventions as the API — camelCase field names and string amounts — so a single set of types and one normalizer cover both surfaces. This is a change from v2, where webhook payloads diverged from API responses; your webhook handlers and signature verification (now 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/quotesQuoteCreateRequest

Three failure modes, all returning 400 VALIDATION_BODY_FAILED:
Invalid — 'amount' + 'referencedUnit' unknown; neither buyAmount nor sellAmount supplied (oneOf unsatisfied)
Invalid — typo in 'sellAmount' (sent as 'sellAmout'); 'oneOf' also unsatisfied
Invalid — both amounts set (no unknown fields, but violates oneOf exactly-one)
The third case is not an unknown-field failure — every key is recognized — but it fails the 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/tradesTradeCreateRequest

The body legally carries only quoteId. Any other key is rejected:
Invalid — 'quoteId' is valid, but 'amount' is an extraneous field
Invalid — typo ('quoteIdd' instead of 'quoteId')

POST /v3/fx/withdrawalsWithdrawalCreateRequest

Legal fields are withdrawalAccountId, withdrawalAmount, currency. Everything else is rejected:
Invalid — v2 shape (renamed fields: 'withdrawalAddressId' → 'withdrawalAccountId', 'amount' → 'withdrawalAmount'; v2 also sent 'amount' as a number, not a string)
Invalid — typo ('withdrawlAmount' instead of 'withdrawalAmount')
Invalid — speculative or response-only field on the request body
The third case demonstrates a common mistake: 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 } so res.data.data happens to still work for the array but res.data.pagination is what carries the cursors, and has_more is gone entirely. Strip the wrapper centrally, not per call site.
  • Sending amounts as numbers from a Decimal library. Most serializers default to JSON numbers. Call .toString() explicitly. See Amounts: common mistakes.
  • Catching errors by HTTP status alone. v3 separates code, type, and status. Branch on error.code for behavior; status mapping has shifted (*_NOT_FOUND 400→404, INSUFFICIENT_BALANCE 400→409). See Errors: catch-and-branch.
  • Updating the URL but not the request body shape. Quote create takes buyCurrency / sellCurrency (renamed from v2’s buy / sell) + exactly one of buyAmount / sellAmount. The v2 amount + referencedUnit pair is gone — sending it fails the v3 schema with 400 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: withdrawalAddressIdwithdrawalAccountId and amountwithdrawalAmount (withdrawal), and the quote shape (amount + referencedUnit → exactly one of buyAmount / sellAmount; buy/sellbuyCurrency/sellCurrency; quoteForSeconds carries over unchanged). Also watch for v2 response-side fields (transactionHash, withdrawalId) accidentally posted back to the server — transactionHash is response-only in v3, never a request field, so it’s rejected. One stray amount: 1000 next to a quoteId is enough to fail a POST /v3/fx/trades call.
  • 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

Yes, on input — v3 still accepts a bare UUID v4 wherever an ID is expected, so any v2 ID you’ve persisted resolves against the corresponding v3 endpoint without translation. v3 responses now return a readable, typed-prefix ID instead (e.g. tde_5W7guYdHT24JFnRQrZN9y8); use that form going forward. See Resource IDs.
Yes — the same EC private key signs both v2 and v3. No new credential issuance is required. The signing flow changes at the call site: the JWT TTL ceiling drops to 60s and every v3 request additionally carries an 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.
v3 separates schema violations (your JSON is malformed → 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.
Feature. Quotes are single-use in v3: the first successful trade flips the quote’s 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.
Yes. v3 webhooks are redesigned to match the API: a resource-noun 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.
No. Every breaking change is a hard cutover at the call site. The compatibility strategy is to run v2 and v3 side-by-side behind a feature flag and flip one resource at a time — see Cutover strategy. The base host, API key, and signing flow are identical, so side-by-side is cheap.

What’s next

Quickstart

A worked quote → trade → balance flow to copy.

Errors

Switch from message-string matching to stable error.code.

Amounts

Parse and send string-encoded monetary values.

Pagination

Replace page numbers with cursor-based paging.