> ## Documentation Index
> Fetch the complete documentation index at: https://api-docs-v3.openfx.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Migration from v2

> Endpoint-by-endpoint mapping from v2 to v3, every field rename, and the breaking changes ranked by impact.

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.

<Warning>
  **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.
</Warning>

<Warning>
  **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](/v3/authentication) before cutting any traffic.
</Warning>

<Tip>
  **Triage in five minutes.** Run two `grep`s 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.
</Tip>

## What changed vs v2

| Area                 | v2                                           | v3                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| -------------------- | -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| URL prefix           | `/v2/brokerage/{orgId}/...`                  | `/v3/fx/...` (org inferred from API key)                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| Resource naming      | Action-y (`generate_quote`, `executeTrade`)  | Nouns (`POST /v3/fx/quotes`, `POST /v3/fx/trades`)                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| Success envelope     | `{ status, data, message }`                  | `{ data: <resource> }` for single resources, or `{data, pagination: {limit, hasNext, nextCursor, hasPrev, prevCursor}}` for paginated lists. `POST /withdrawals` also nests an optional `metadata` object inside `data` (`data.metadata`); no other endpoint returns `metadata`.                                                                                                                                                                                                                      |
| Amounts              | Numbers (`1000.50`)                          | Strings with prefixes (`"buyAmount": "1000.50"`)                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| Time                 | Durations (`expiryTimeInSeconds`)            | Timestamps (`expiresAt`)                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| Pagination           | `?page=1&limit=10` flat array                | Cursor (`?startingAfter=<opaque cursor>&limit=25`); response carries `pagination.nextCursor` / `prevCursor` (null at the boundary)                                                                                                                                                                                                                                                                                                                                                                    |
| Errors               | `{ status: "error", message }`               | `{ error: { code, type, message, details } }` (all four required; `details` is `{}` when empty)                                                                                                                                                                                                                                                                                                                                                                                                       |
| Trace ID             | Not exposed                                  | `X-Trace-Id` + `X-Request-Timestamp` on every response                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| Rate limits          | Cloudflare-only, no headers                  | `RateLimit-Limit` / `RateLimit-Reset` on every response, `Retry-After` on `429`/`409 IDEMPOTENCY_IN_FLIGHT`. 300 req/10s on Live, 600 req/10s on Sandbox                                                                                                                                                                                                                                                                                                                                              |
| Withdrawal endpoints | Fiat + stablecoin = 2 endpoints              | Merged into one. Account `type` inferred from `withdrawalAccountId`.                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| Trade response       | Bundles balances, credit, the trade          | Returns the trade only. Query balances separately.                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| Resource IDs         | Bare UUID v4                                 | Readable, typed-prefix ID on responses (e.g. `tde_5W7guYdHT24JFnRQrZN9y8`) — **v2-stored raw UUIDs still work on input**, no migration required. See [Resource IDs](/v3/resource-ids).                                                                                                                                                                                                                                                                                                                |
| Authentication       | Bearer JWT (max 120s TTL), no body signature | Bearer JWT (**max 60s TTL**) + **`X-Request-Signature`** (ES256 over canonical string). Same EC key signs both. See [Authentication](/v3/authentication).                                                                                                                                                                                                                                                                                                                                             |
| Webhooks             | v2 envelope (flat `type`, numeric amounts)   | Reshaped envelope `{ id, type, eventType, createdAt, data }`: `type` is the resource noun (`"deposits"` / `"withdrawals"`), `eventType` is the dotted event (`"deposit.completed"`), `data` is the resource **object** in v3 shape (camelCase keys + **string** amounts). Verify the `X-OpenFX-Signature` header; v3 also sends `X-OpenFX-Webhook-Version: v3` and a legacy `X-REDENVELOPE-SIGNATURE` alias (same secret — prefer `X-OpenFX-Signature`). See [Webhooks](/v3/webhooks/authentication). |

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

<Update label="v3" tags={["breaking", "migration"]}>
  Three tiers, ranked by client-side migration cost — highest first.{" "}
  <Badge color="red">Tier 1</Badge> is the silent-corruption risk;{" "}
  <Badge color="yellow">Tier 2</Badge> breaks loudly;{" "}
  <Badge color="blue">Tier 3</Badge> is housekeeping.
</Update>

### <Badge color="red">Tier 1</Badge> 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.

<AccordionGroup>
  <Accordion title="Quote create request schema is hard-broken">
    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.

    ```diff theme={null}
    - // v2: POST /v2/brokerage/{orgId}/generate_quote
    - {
    -   "buy": "USDC",
    -   "sell": "USD",
    -   "amount": 1000.50,
    -   "referencedUnit": "USD",
    -   "quoteForSeconds": 30
    - }

    + // v3: POST /v3/fx/quotes — anchor the sell side (you choose what to spend)
    + {
    +   "buyCurrency": "USDC",
    +   "sellCurrency": "USD",
    +   "sellAmount": "1000.50",
    +   "quoteForSeconds": 30
    + }
    ```

    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](#field-renames-quote) for the full delta.
  </Accordion>
</AccordionGroup>

### <Badge color="yellow">Tier 2</Badge> 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.

<AccordionGroup>
  <Accordion title="Success-response envelope removed">
    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`.

    ```diff theme={null}
    - // v2
    - {
    -   "status": "success",
    -   "data": { "trade": { "id": "...", "buyCurrency": "USDC", ... } },
    -   "message": "Data fetched successfully"
    - }

    + // v3
    + { "data": { "id": "...", "buyCurrency": "USDC", ... } }
    ```

    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`.
  </Accordion>

  <Accordion title="URL paths">
    ```diff theme={null}
    - POST /v2/brokerage/{orgId}/trade
    + POST /v3/fx/trades
    ```

    * Drop `{orgId}` everywhere. It's derived from the API key.
    * `brokerage` → `fx`
    * Action names → resource nouns
  </Accordion>

  <Accordion title="Amounts are strings">
    ```diff theme={null}
    - // v2
    - { "amount": 1000.50, "buy": "USDC" }

    + // v3
    + { "buyAmount": "1000.50", "buyCurrency": "USDC" }
    ```

    JSON parsers that strongly type numbers (Java, Kotlin, Swift, Go with `int64`) need to switch to string parsing. See [Amounts](/v3/amounts).
  </Accordion>

  <Accordion title="Pagination is cursor-based">
    ```diff theme={null}
    - // v2: ?page=2&limit=25 — flat array response
    + // v3: ?limit=25&startingAfter=<opaque cursor string>
    ```

    The response shape changes from a flat array to an explicit envelope:

    ```diff theme={null}
    - // v2: flat array under data
    - { "data": [ /* records */ ] }   // page number tracked client-side via ?page=

    + // v3: explicit pagination envelope
    + {
    +   "data": [ /* records, newest-first */ ],
    +   "pagination": {
    +     "limit": 25,
    +     "hasNext": true,
    +     "nextCursor": "<opaque string or null>",
    +     "hasPrev": false,
    +     "prevCursor": "<opaque string or null>"
    +   }
    + }
    ```

    | v2                       | v3                                                                         | Notes                                                                                                                                                                                             |
    | ------------------------ | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
    | `?page=N`                | `?startingAfter=<cursor>` (forward) or `?endingBefore=<cursor>` (backward) | Cursors are opaque strings — pass them back verbatim, do not decode.                                                                                                                              |
    | Flat `data: [...]` array | `{ data: [...], pagination: {...} }`                                       | The pagination envelope is a sibling field of `data`.                                                                                                                                             |
    | `has_more: boolean`      | `pagination.hasNext` / `pagination.hasPrev` (booleans)                     | `has_more` is replaced by two booleans — `hasNext` (more forward) and `hasPrev` (more backward). The matching cursor (`nextCursor` / `prevCursor`) is `null` exactly when its boolean is `false`. |
    | Total count / page count | (not exposed)                                                              | `total`, `total_count`, `total_pages` are intentionally absent — `COUNT(*)` is too taxing at scale. Use cursors.                                                                                  |
    | Cursor = a resource ID   | Cursor = an opaque token                                                   | Cursors are opaque tokens so the encoding can change without breaking clients. Don't decode or trim them — pass them back verbatim.                                                               |

    See [Pagination](/v3/pagination) for worked forward + backward iteration examples and the full opacity contract.
  </Accordion>

  <Accordion title="Trade response no longer bundles balances">
    ```diff theme={null}
    - // v2 POST /trade response
    - {
    -   "data": {
    -     "trade": { ... },
    -     "balances": { "USD": 5000, "USDC": 100 },
    -     "userCreditLimit": 10000000,
    -     "userCreditUsed": 8844000.51
    -   }
    - }

    + // v3 POST /trades response
    + { "id": "...", "buyAmount": "...", ... }
    + // Fetch GET /v3/fx/balances separately if needed
    ```

    If your post-trade flow relied on the bundled `balances`, add a `GET /v3/fx/balances` call after the trade.
  </Accordion>

  <Accordion title="Errors are envelope-shaped with machine-readable codes">
    ```diff theme={null}
    - // v2
    - { "status": "error", "message": "Insufficient balance" }

    + // v3
    + {
    +   "error": {
    +     "code": "WITHDRAWAL_INSUFFICIENT_BALANCE",
    +     "type": "CONFLICT",
    +     "message": "Insufficient balance for this withdrawal.",
    +     "retryStrategy": "FIX_AND_RESUBMIT",
    +     "details": { "requiredAmount": "1000.00", "availableAmount": "250.00", "currency": "USD" }
    +   }
    + }
    ```

    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](/v3/errors#triage-matrix)). There is no `requestId` in the body — correlate via the `X-Trace-Id` response header. See [Errors](/v3/errors).
  </Accordion>

  <Accordion title="Fiat + stablecoin withdrawals merged">
    ```diff theme={null}
    - // v2: two endpoints
    - POST /v2/.../withdrawal           // stablecoin
    - POST /v2/.../fiat_withdrawal      // fiat

    + // v3: one endpoint, asset type inferred from withdrawalAccountId
    + POST /v3/fx/withdrawals
    + // body: { withdrawalAccountId, withdrawalAmount, currency }
    ```

    `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.
  </Accordion>

  <Accordion title="Quote single-use: second trade returns 409 QUOTE_ALREADY_CONSUMED">
    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.

    ```diff theme={null}
    - // v2: same quoteId could back multiple trades
    + // v3: second trade against the same quoteId
    + 409 QUOTE_ALREADY_CONSUMED
    + { "error": { "code": "QUOTE_ALREADY_CONSUMED", "type": "CONFLICT", ... } }
    ```

    The `Quote.status` enum (`ACTIVE | EXPIRED | CONSUMED`, visible via `GET /v3/fx/quotes/{id}`) lets you check state before retrying. See [Field renames: Quote](#field-renames-quote).
  </Accordion>

  <Accordion title="Balance shape: flat map → array of Balance resources">
    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).

    ```diff theme={null}
    - // v2
    - { "USD": 5000.00, "USDC": 100.00 }

    + // v3
    + {
    +   "data": [
    +     { "currency": "USD",  "availableBalance": "5000.00", "totalBalance": "5000.00" },
    +     { "currency": "USDC", "availableBalance": "100.00",  "totalBalance": "100.00" }
    +   ]
    + }
    ```

    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.
  </Accordion>

  <Accordion title="Status enum casing: uppercase canonical">
    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.
  </Accordion>

  <Accordion title="Unknown request-body fields are now rejected (400 VALIDATION_BODY_FAILED)">
    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.

    ```diff theme={null}
    - // v2: extra fields silently dropped, request still succeeds
    - { "quoteId": "...", "amount": 1000 }   // 'amount' silently ignored, trade still creates

    + // v3: same body fails fast
    + 400 VALIDATION_BODY_FAILED
    + { "error": { "code": "VALIDATION_BODY_FAILED",
    +              "details": { "issues": [{ "path": "/amount", "code": "unrecognized_keys" }] } } }
    ```
  </Accordion>
</AccordionGroup>

### <Badge color="blue">Tier 3</Badge> 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.

<AccordionGroup>
  <Accordion title="HTTP status code refinements (400 → 404, 409, 422, 428)">
    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](#http-status-changes) below. If your v2 client did `if
            (status === 400) {showFieldError()}`, switch on `error.code` instead.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="Idempotency-Replayed response header — new in v3">
    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.
  </Accordion>

  <Accordion title="Resource IDs are now 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; 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/resource-ids).
  </Accordion>

  <Accordion title="Client-side correlation is now X-Request-Id (replaces the Request-Id header)">
    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](/v3/metadata-and-tracing#client-request-correlation).
  </Accordion>
</AccordionGroup>

## Endpoint map

Every v2 endpoint and its v3 replacement.

| v2                                                    | v3                               | Notes                                                                                                                                             |
| ----------------------------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /v2/brokerage/{orgId}/available_markets`         | `GET /v3/fx/pairs`               | Renamed to plural resource noun                                                                                                                   |
| `POST /v2/brokerage/{orgId}/generate_quote`           | `POST /v3/fx/quotes`             | Idempotency required (30 min TTL)                                                                                                                 |
| `POST /v2/brokerage/{orgId}/trade`                    | `POST /v3/fx/trades`             | Response stripped of balances/credit                                                                                                              |
| `GET /v2/brokerage/{orgId}/trade/{id}`                | `GET /v3/fx/trades/{id}`         | 404 on missing (v2 returned 400)                                                                                                                  |
| `GET /v2/brokerage/{orgId}/trades`                    | `GET /v3/fx/trades`              | Cursor pagination                                                                                                                                 |
| `GET /v2/brokerage/{orgId}/balances`                  | `GET /v3/fx/balances`            | Returns array of `Balance` per currency, not a flat map                                                                                           |
| `GET /v2/brokerage/{orgId}/deposits`                  | `GET /v3/fx/deposits`            | Cursor pagination                                                                                                                                 |
| *(none)*                                              | `GET /v3/fx/deposits/{id}`       | New in v3 — fetch a single deposit by id (no v2 equivalent)                                                                                       |
| `POST /v2/brokerage/{orgId}/withdrawal`               | `POST /v3/fx/withdrawals`        | Merged with fiat                                                                                                                                  |
| `POST /v2/brokerage/{orgId}/fiat_withdrawal`          | `POST /v3/fx/withdrawals`        | Merged with stablecoin                                                                                                                            |
| `GET /v2/brokerage/{orgId}/withdrawal/{withdrawalId}` | `GET /v3/fx/withdrawals/{id}`    | 404 on missing                                                                                                                                    |
| `GET /v2/brokerage/{orgId}/withdrawals`               | `GET /v3/fx/withdrawals`         | Cursor pagination. No server-side filter — split client-side by `network`                                                                         |
| `GET /v2/brokerage/{orgId}/withdrawal_addresses`      | `GET /v3/fx/withdrawal-accounts` | Merged. Server-side filters: `?assetType`, `?verified`, `?status`, `?currency`, `?network`; see below for v2 `WALLET_FILTER_*` error-code mapping |
| `GET /v2/brokerage/{orgId}/fiat_withdrawal_addresses` | `GET /v3/fx/withdrawal-accounts` | Merged. Server-side filters: `?assetType`, `?verified`, `?status`, `?currency`, `?network`. See `Field renames: WithdrawalAccount` below          |

<Note>
  **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
  (`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.
</Note>

## Field renames: Trade

| v2                   | v3                                                                                                                                                |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `buy`                | `buyCurrency`                                                                                                                                     |
| `sell`               | `sellCurrency`                                                                                                                                    |
| `amount`             | `buyAmount` / `sellAmount` (whichever side the trade was specified in) plus `executedAmount` — the final amount received in the counter-currency. |
| `referencedUnit`     | **removed** — the amount field name (`buyAmount` vs `sellAmount`) now tells you which side was anchored.                                          |
| `referencedAmount`   | **removed** — covered by `buyAmount` / `sellAmount` / `executedAmount`.                                                                           |
| *(none)*             | `clientReferenceId` — **NEW**, optional. A client-supplied correlation string, echoed back on the Trade.                                          |
| `transactedAt`       | `createdAt`                                                                                                                                       |
| *(none)*             | `createdAt` is the Trade's single timestamp — when the execute request was accepted. v2's `transactedAt` maps here; there is no `executedAt`.     |
| `quoteId`            | `quoteId`                                                                                                                                         |
| `status: "EXECUTED"` | `status: "EXECUTED"` (unchanged — uppercase preserved)                                                                                            |

## Field renames: Quote

<Warning>
  **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.
</Warning>

**v2 → v3 quote-create request, side-by-side:**

```diff theme={null}
- // v2: POST /v2/brokerage/{orgId}/generate_quote
- {
-   "buy": "USDC",
-   "sell": "USD",
-   "amount": 1000.50,
-   "referencedUnit": "USD",
-   "quoteForSeconds": 30
- }

+ // v3: POST /v3/fx/quotes — anchor the sell side (you choose what to spend)
+ {
+   "buyCurrency": "USDC",
+   "sellCurrency": "USD",
+   "sellAmount": "1000.50",
+   "quoteForSeconds": 30
+ }
+
+ // OR — v3 anchor the buy side (you choose what to receive)
+ {
+   "buyCurrency": "USDC",
+   "sellCurrency": "USD",
+   "buyAmount": "1000.00"
+ }
```

| v2 request body   | v3 request body                                                                                                                            |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `amount`          | `buyAmount` OR `sellAmount` (supply **exactly one**)                                                                                       |
| `buy`             | `buyCurrency`                                                                                                                              |
| `sell`            | `sellCurrency`                                                                                                                             |
| `referencedUnit`  | **removed** — the field name (`buyAmount` vs `sellAmount`) now tells the server which side you're locking.                                 |
| `quoteForSeconds` | `quoteForSeconds` (unchanged name) — closed enum `3 \| 15 \| 30 \| 45 \| 60`, defaults to `3`.                                             |
| *(none)*          | `settlementWindow` — **NEW**, optional. Settlement window for the resulting trade (`IMMEDIATE` \| `T0` \| `T1` \| `T2`), defaults to `T0`. |

| v2 response                           | v3 response                                                                                                                                                                                                                                                                                                                                               |
| ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `expiryTimeInSeconds`                 | `expiresAt` (ISO timestamp, not a duration)                                                                                                                                                                                                                                                                                                               |
| `referencedAmount` / `referencedUnit` | **removed** — `buyAmount` or `sellAmount` (whichever you supplied) plus `quoteAmount` — the server-computed counter-leg amount — are returned.                                                                                                                                                                                                            |
| *(none)*                              | `quoteAmount` — **NEW**. The counter-leg amount, computed at the quoted rate.                                                                                                                                                                                                                                                                             |
| *(none)*                              | `settlementWindow` — **NEW**. Echoes the request's settlement window.                                                                                                                                                                                                                                                                                     |
| *(none)*                              | `status: "ACTIVE" \| "EXPIRED" \| "CONSUMED"` — **NEW**, only on `GET /v3/fx/quotes/{id}` (not on the `POST /v3/fx/quotes` create response). `CONSUMED` once a trade executes against the quote. Useful for pre-execution verification via [`GET /v3/fx/quotes/{id}`](/v3/api-reference/trade/get-quote-by-id) without re-deriving the state client-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

| v2                                                      | v3                                                                                                                                                                                                     |
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `amount`                                                | `withdrawalAmount` (request body **and** response; numeric → string; always positive). Every monetary field in v3 carries a qualifier — there is no bare `amount`.                                     |
| `withdrawalId`                                          | `id` (path param renamed)                                                                                                                                                                              |
| `walletAddress` (stablecoin) / fiat address id          | `withdrawalAccountId` — single field referencing a `WithdrawalAccount.id`. Renamed from the v2 request field `withdrawalAddressId`; the destination merges to one verified account regardless of rail. |
| `transactionHash`                                       | `transactionHash` (unchanged) — on-chain transaction hash for crypto withdrawals once broadcast, `null` otherwise.                                                                                     |
| `network`                                               | `network` (unchanged) — the rail or chain the withdrawal settled over.                                                                                                                                 |
| *(none)*                                                | `paymentDetails` — **NEW**, nullable. Rail-specific reference data for fiat withdrawals (e.g. FEDWIRE IMAD/OMAD), keyed by transfer type. `null` for crypto or before terminal state.                  |
| *(none)*                                                | `actorId` / `actorEmail` — **NEW**. Identity of who initiated the withdrawal. Masked to a fixed placeholder when the actor is an internal OpenFX operator.                                             |
| *(none)*                                                | `comments` — **NEW**, nullable. Free-text notes about the withdrawal.                                                                                                                                  |
| *(none)*                                                | `estimatedCompletionAt` — **NEW**, nullable. When the withdrawal is expected to settle; `null` if there's no ETA yet.                                                                                  |
| `completedAt`                                           | (not exposed in v3)                                                                                                                                                                                    |
| Separate `/withdrawal` and `/fiat_withdrawal` endpoints | One endpoint; rail resolved server-side from `withdrawalAccountId`                                                                                                                                     |

## Field renames: Balance

v2 returned a flat `{currency: amount}` map. v3 returns an array of `Balance` resources.

| v2 (flat map)               | v3 (Balance resource)                                                             |
| --------------------------- | --------------------------------------------------------------------------------- |
| `{ "USD": 5000.00 }`        | `{ "currency": "USD", "availableBalance": "5000.00", "totalBalance": "5000.00" }` |
| (no per-currency breakdown) | Each currency exposes `availableBalance` / `totalBalance` separately              |

## Field renames: Deposit

| v2                                      | v3                                                                                                                                                                                          |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `amount`                                | `depositAmount` (numeric → string). Every monetary field in v3 carries a qualifier — there is no bare `amount`.                                                                             |
| `referenceId`                           | `referenceId` (unchanged) — external reference id supplied by the customer, if any.                                                                                                         |
| `transactionHash`                       | `transactionHash` (unchanged) — on-chain transaction hash for crypto deposits, `null` otherwise.                                                                                            |
| `network`                               | `network` (unchanged) — settlement network (e.g. `ETHEREUM`, `FIAT`).                                                                                                                       |
| *(none)*                                | `paymentDetails` — **NEW**, nullable. Rail-specific reference data for fiat deposits (e.g. FEDWIRE IMAD/OMAD), keyed by transfer type. `null` for crypto deposits or without rail metadata. |
| *(none)*                                | `comments` / `memo` — **NEW**, nullable. Free-text notes and an optional memo line captured at deposit time.                                                                                |
| `status: "COMPLETED"`                   | `status: "COMPLETED"` (uppercase preserved). Closed enum: `PENDING`, `COMPLETED`, `ERROR`.                                                                                                  |
| *(missing in v2 spec; only in webhook)* | `createdAt` is always populated                                                                                                                                                             |

## 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": [ ... ] }`.

| v2 `Market`                         | v3 `Pair`                                                                           |
| ----------------------------------- | ----------------------------------------------------------------------------------- |
| `buy` / `sell` (strings)            | `buyCurrency` / `sellCurrency`                                                      |
| `minAmount` / `maxAmount` (numbers) | `minTradeAmount` / `maxTradeAmount` (strings, denominated in the **sell** currency) |

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

| v2 fiat field (`fiat_withdrawal_addresses`)           | v3 `WithdrawalAccount`                                                                                            |
| ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `accountName`                                         | `destination.accountName`                                                                                         |
| `bankName`                                            | `destination.bankName`                                                                                            |
| `accountNumber`                                       | `destination.accountNumber` (full value, unchanged)                                                               |
| `routingNumber`                                       | `destination.routingNumber` (on Fedwire-rail destinations; SWIFT/FPS destinations use `swiftCode` / `ukSortCode`) |
| `swiftCode`                                           | `destination.swiftCode` (on the SWIFT-rail destination shape)                                                     |
| `transferType` (ACH, WIRE, SWIFT, FEDWIRE, SEPA, ...) | `rail` (e.g. `SWIFT`, `FEDWIRE`, `ACH`, `SEPA`) — also determines which `destination` shape applies               |
| `memo`                                                | `destination.memo`                                                                                                |
| `verified: true`                                      | `verified: true`                                                                                                  |
| `verified: false`                                     | `verified: false` or `null` (upstream verification state not always set)                                          |
| *(was the endpoint URL in v2)*                        | `assetType: "FIAT"`                                                                                               |
| `updatedAt`                                           | `updatedAt`                                                                                                       |
| `createdAt`                                           | `createdAt`                                                                                                       |
| `creatorId`                                           | `creatorId` — now exposed (nullable; `null` only for legacy rows)                                                 |
| `orgId`                                               | (not exposed; org resolved from API key)                                                                          |

| v2 stablecoin field (`withdrawal_addresses`) | v3 `WithdrawalAccount`                                                       |
| -------------------------------------------- | ---------------------------------------------------------------------------- |
| `name`                                       | `displayName`                                                                |
| `coinName` (e.g. `"USDC"`)                   | `currency`                                                                   |
| `coinNetworkName` (e.g. `"Ethereum"`)        | `rail` (e.g. `"ETHEREUM"` — uppercase canonical chain name)                  |
| `coinId`, `coinNetworkId`                    | Not exposed in v3                                                            |
| `address`                                    | `destination.address` (full wallet address — non-sensitive, public on-chain) |
| *(was the endpoint URL in v2)*               | `assetType: "CRYPTO"`                                                        |
| `verified: true`                             | `verified: true`                                                             |

| New in v3        | Purpose                                                                                                                                                                                                         |
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `assetType` enum | `"CRYPTO"` \| `"FIAT"` (SCREAMING\_SNAKE, per the enum-casing standard). Replaces the v2 "which endpoint did I call to get this record" implicit signal. Note this is `CRYPTO`/`FIAT`, not `FIAT`/`STABLECOIN`. |
| `rail`           | Rail identifier (chain name for crypto; `SWIFT`/`FEDWIRE`/`ACH`/`SEPA`/etc. for fiat) — determines the shape of `destination`.                                                                                  |
| `destination`    | Rail-specific destination details, discriminated by `rail`. Replaces the flat, endpoint-specific fields v2 spread across its two separate schemas.                                                              |
| `status` enum    | `PENDING` \| `ACTIVE` \| `ARCHIVED` \| `REJECTED` \| `DEACTIVATED` \| `ADDITIONAL_ACTION_NEEDED` — richer than v2's implicit `verified` boolean. Only `ACTIVE` accounts accept withdrawals.                     |

**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](/v3/errors#error-code-catalog).

## 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](/v3/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.

| Scenario / Error code                                                    | v2                        | v3                                     | Why                                                                                                                                                                                                                                                                                                                                                                                 |
| ------------------------------------------------------------------------ | ------------------------- | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `TRADE_NOT_FOUND`, `WITHDRAWAL_NOT_FOUND`                                | 400                       | **404**                                | Resources that don't exist deserve standard not-found semantics                                                                                                                                                                                                                                                                                                                     |
| `WITHDRAWAL_INSUFFICIENT_BALANCE`                                        | 400                       | **409**                                | State-driven conflict: well-formed request blocked by current balance                                                                                                                                                                                                                                                                                                               |
| `QUOTE_EXPIRED`                                                          | 400                       | **409**                                | State-driven conflict: quote was valid, TTL lapsed                                                                                                                                                                                                                                                                                                                                  |
| `WITHDRAWAL_ADDRESS_NOT_VERIFIED`                                        | 400                       | **409**                                | State-driven conflict: account exists but isn't ready yet                                                                                                                                                                                                                                                                                                                           |
| `IDEMPOTENCY_KEY_MISSING`                                                | 400                       | **428**                                | RFC 6585 Precondition Required (the header is required)                                                                                                                                                                                                                                                                                                                             |
| `IDEMPOTENCY_KEY_INVALID`                                                | 400                       | 400                                    | Code rename only (`INVALID_IDEMPOTENCY_KEY` → `IDEMPOTENCY_KEY_INVALID`); status unchanged                                                                                                                                                                                                                                                                                          |
| `WITHDRAWAL_INVALID_AMOUNT_PRECISION`                                    | 400                       | **422**                                | Well-formed number, semantic precision violation                                                                                                                                                                                                                                                                                                                                    |
| `TRADE_AMOUNT_ABOVE_LIMIT`, `TRADE_AMOUNT_BELOW_MINIMUM`                 | 400                       | **422**                                | Well-formed amount, business-rule violation                                                                                                                                                                                                                                                                                                                                         |
| `WITHDRAWAL_ADDRESS_CURRENCY_MISMATCH`                                   | 400                       | **422**                                | Cross-field semantic rule                                                                                                                                                                                                                                                                                                                                                           |
| `WITHDRAWAL_ADDRESS_CHECKSUM_FAILED`                                     | 400                       | **422**                                | Well-formed string, semantically invalid for the network                                                                                                                                                                                                                                                                                                                            |
| `AUTH_API_KEY_SANDBOX_REQUIRED`, `AUTH_API_KEY_LIVE_REQUIRED`            | 401                       | **403**                                | Authentication succeeded; environment policy rejected                                                                                                                                                                                                                                                                                                                               |
| `SANDBOX_MODE_REQUIRED`                                                  | 400                       | **403**                                | Authenticated request rejected by environment policy                                                                                                                                                                                                                                                                                                                                |
| `AUTH_FORBIDDEN_IP`, `AUTH_INSUFFICIENT_SCOPE`                           | 401                       | **403**                                | Authenticated, but not allowed                                                                                                                                                                                                                                                                                                                                                      |
| `MARKET_FETCH_FAILED` (v2) → `INTERNAL_ERROR` (v3)                       | 400 or 500 (inconsistent) | **500** (consistent)                   | Read-path 500s collapse into the catch-all `INTERNAL_ERROR` instead of a per-resource code. The `Market` → `Pair` resource rename still applies to success responses; only the error code changed.                                                                                                                                                                                  |
| Read-path 500s on trades, quotes, withdrawals, deposits, balances, pairs | 500 (generic)             | **500** `INTERNAL_ERROR`               | v2-style codes like `TRADE_FETCH_FAILED`, `QUOTE_FETCH_FAILED`, `WITHDRAWAL_FETCH_FAILED`, `DEPOSIT_LIST_FAILED`, `BALANCE_FETCH_FAILED` collapse into the single catch-all. Write-path 500s on trades and withdrawals keep their domain codes (`TRADE_EXECUTION_FAILED`, `WITHDRAWAL_INITIATION_FAILED`) because those carry `resource_id` for the check-state-then-retry pattern. |
| v1 endpoints after Dec 31, 2026                                          | (200 prior to sunset)     | **410** with `Sunset` + `Link` headers | RFC 8594 sunset signal points clients at the v3 replacement                                                                                                                                                                                                                                                                                                                         |

<Tip>
  **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`.
</Tip>

<Warning>
  **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](/v3/errors#retrying-trade-execution-failed).
</Warning>

## 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](/v3/errors#error-envelope-fields) 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.

| Footprint                             | Markers in your v2 code                                       | Expected effort |
| ------------------------------------- | ------------------------------------------------------------- | --------------- |
| Read-only (pairs, balances, history)  | No write endpoints; no `Idempotency-Key` generated anywhere   | 1–3 days        |
| Quote → trade only                    | Call sites for `/generate_quote` and `/trade`; no withdrawals | 3–7 days        |
| Full trading + withdrawals + webhooks | All of the above plus `/withdrawal*` and webhook handlers     | 1.5–3 weeks     |

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.

<Steps>
  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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).
  </Step>

  <Step title="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](/v3/webhooks/authentication).
  </Step>
</Steps>

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](/v3/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.)

<Note>
  **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](/v3/webhooks/authentication) for the redesigned
  envelope.
</Note>

## 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`:

```json title="Invalid — 'amount' + 'referencedUnit' unknown; neither buyAmount nor sellAmount supplied (oneOf unsatisfied)" theme={null}
{
  "buyCurrency": "USDC",
  "sellCurrency": "USD",
  "amount": 1000,
  "referencedUnit": "USD"
}
```

```json title="Invalid — typo in 'sellAmount' (sent as 'sellAmout'); 'oneOf' also unsatisfied" theme={null}
{
  "buyCurrency": "USDC",
  "sellCurrency": "USD",
  "sellAmout": "1000.00"
}
```

```json title="Invalid — both amounts set (no unknown fields, but violates oneOf exactly-one)" theme={null}
{
  "buyCurrency": "USDC",
  "sellCurrency": "USD",
  "buyAmount": "1000.00",
  "sellAmount": "1000.00"
}
```

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/trades` — `TradeCreateRequest`

The body legally carries only `quoteId`. Any other key is rejected:

```json title="Invalid — 'quoteId' is valid, but 'amount' is an extraneous field" theme={null}
{
  "quoteId": "d8bc9618-2830-4822-9ae2-414aaf2b2de3",
  "amount": 1000
}
```

```json title="Invalid — typo ('quoteIdd' instead of 'quoteId')" theme={null}
{
  "quoteIdd": "d8bc9618-2830-4822-9ae2-414aaf2b2de3"
}
```

### `POST /v3/fx/withdrawals` — `WithdrawalCreateRequest`

Legal fields are `withdrawalAccountId`, `withdrawalAmount`, `currency`. Everything else is rejected:

```json title="Invalid — v2 shape (renamed fields: 'withdrawalAddressId' → 'withdrawalAccountId', 'amount' → 'withdrawalAmount'; v2 also sent 'amount' as a number, not a string)" theme={null}
{
  "withdrawalAddressId": "8ff46b97-5742-4196-88dd-db7b0b46ab7a",
  "amount": 1000,
  "currency": "USD"
}
```

```json title="Invalid — typo ('withdrawlAmount' instead of 'withdrawalAmount')" theme={null}
{
  "withdrawalAccountId": "8ff46b97-5742-4196-88dd-db7b0b46ab7a",
  "withdrawlAmount": "1000.00",
  "currency": "USD"
}
```

```json title="Invalid — speculative or response-only field on the request body" theme={null}
{
  "withdrawalAccountId": "8ff46b97-5742-4196-88dd-db7b0b46ab7a",
  "withdrawalAmount": "1000.00",
  "currency": "USD",
  "transactionHash": "0xabc123…"
}
```

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](/v3/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](/v3/errors#handling-errors-the-catch-and-branch-pattern).
* **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](#schema-enforcement-unknown-fields-rejected).
* **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` → `withdrawalAccountId` and `amount` → `withdrawalAmount` (withdrawal), and the quote shape (`amount` + `referencedUnit` → exactly one of `buyAmount` / `sellAmount`; `buy`/`sell` → `buyCurrency`/`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](/v3/idempotency#how-to-generate-keys).

## Frequently asked questions

<AccordionGroup>
  <Accordion title="Do my v2 UUIDs still work in v3?">
    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](/v3/resource-ids).
  </Accordion>

  <Accordion title="Can I keep my existing API key?">
    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](/v3/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/environments).
  </Accordion>

  <Accordion title="Why am I now getting 422 where I used to get 400?">
    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](#http-status-changes) table. Switch your error router from HTTP
    status to `error.code` and the split goes away.
  </Accordion>

  <Accordion title="My second trade against the same quoteId returned 409 — bug or feature?">
    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.
  </Accordion>

  <Accordion title="Did webhook payloads change in v3?">
    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](/v3/webhooks/authentication).
  </Accordion>

  <Accordion title="Is there a v2 compat shim?">
    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](#cutover-strategy). The base host, API key, and signing flow are
    identical, so side-by-side is cheap.
  </Accordion>
</AccordionGroup>

## What's next

<CardGroup cols={2}>
  <Card title="Quickstart" icon="rocket" href="/v3/quickstart#3-quote-a-trade">
    A worked quote → trade → balance flow to copy.
  </Card>

  <Card title="Errors" icon="triangle-exclamation" href="/v3/errors">
    Switch from message-string matching to stable `error.code`.
  </Card>

  <Card title="Amounts" icon="dollar-sign" href="/v3/amounts">
    Parse and send string-encoded monetary values.
  </Card>

  <Card title="Pagination" icon="layer-group" href="/v3/pagination">
    Replace page numbers with cursor-based paging.
  </Card>
</CardGroup>
