FX
Two different things are called "a rate" here, and they must not be confused:
- An indicative rate (
GET /v1/fx/rates) is for display. It is not a price anyone will honour. - A quote (
POST /v1/banking/fx/quote) is a price for a specific amount, held untilexpires_at. Executing it converts money between two of your accounts, or it can lock the rate for a payout in another currency.
Indicative rates
GET /v1/fx/rates?base=EUR¤cies=USD,GBP
{
"base": "EUR",
"rates": [
{"currency": "USD", "rate": "0.92", "rate_source": "reference"},
{"currency": "GBP", "rate": "1.2294189258", "rate_source": "reference"}
],
"unavailable": [],
"as_of": "2026-10-15T20:00:37Z"
}
rateis units ofbaseper one unit ofcurrency: above, 1 USD is 0.92 EUR.baseand every entry incurrenciesare three-letter codes; at most 30 currencies per call.- A currency that cannot be priced goes into
unavailablerather than failing the call. In production that happens before your account is approved. rate_sourcesays what kind of price it is, not who supplied it:livewhen the rail that would execute the trade priced it,referencewhen the sandbox's ECB table did — indicative, not a rate anything executes at.- Rates are reused for up to a minute.
This route is on the developer surface, not the money surface, so a
restricted or publishable key can read it with the fx:read scope. Secret
keys, master keys and sessions can always read it.
Quotes
POST /v1/banking/fx/quote
Content-Type: application/json
{"sell_currency": "EUR", "buy_currency": "GBP", "amount": "20.00"}
| Field | Required | Notes |
|---|---|---|
sell_currency | Yes | What leaves your account |
buy_currency | Yes | What arrives |
amount | Yes | Decimal string |
fixed_side | sell (default): sell exactly amount. buy: deliver exactly amount. |
200:
{
"quote_id": "mockfx_7d2f19c4e8a04b6f9e1c3a5b7d9f0e21_970bb58b2ddb4d5e8755863dfd82e5bc",
"sell_currency": "EUR",
"buy_currency": "GBP",
"sell_amount": "20.00",
"buy_amount": "16.11",
"rate": "0.8052584673",
"tier": "1.00",
"expires_at": "2026-10-15T20:18:51Z",
"tradable": true,
"rate_source": "reference"
}
sell_amountis what is debited, any unit fee included;buy_amountis what is credited.rate_sourceislivewhen the rail that would execute the conversion priced it — always, in production — andreferencewhen the sandbox's ECB table did, which is an indication rather than a price. The rail is not named.rateis the rate you get, after our margin.tieris that margin as a percentage ("1.00"is 1%).unit_feeandunit_fee_currencyappear when a fixed fee applies.expires_atis the only lifetime to trust. Sandbox quotes last 15 minutes; production quotes last as long as our partner holds the price, which varies.tradable: falseis not a refusal: the conversion can still be executed, but its settlement timing is not confirmed. Show it to the payer before they agree.
A quote moves nothing and needs no Idempotency-Key.
| Error | When |
|---|---|
400 bad_request | fixed_side not sell/buy; same currency on both sides; amount zero, negative or too precise |
422 corridor_unsupported | Nobody converts this pair — including a currency code we do not know. details.corridor is {"type": "fx", "sell_currency", "buy_currency"}. A standing answer: do not retry it. |
422 market_closed | The pair is outside its market hours; message is written to be shown to the payer |
503 service_unavailable | The rail that offers the pair is down — retry with backoff |
Converting between your accounts
POST /v1/banking/fx/execute
Idempotency-Key: convert-2026-10-15-001
Content-Type: application/json
{"quote_id": "mockfx_…", "account_id": "mockacct_…_eur"}
account_id is the account that sells — it must hold the quote's
sell_currency. Hold an account in the buy currency before you convert; the
proceeds are credited to it.
200:
{
"deal_id": "sandbox_fx_6f1e3a9c2b7d4e5f8a0c1d2e3f4a5b6c",
"status": "settled",
"raw": {"simulated": true, "quote_id": "mockfx_…"}
}
- Sandbox: booked and
settledin the response. - Production:
status: "booked"; it settles later, as the partner reports.rawcarries the partner's execution response underresponse, next to thequoteIdand the account it was executed from.
The conversion is also recorded as a transaction you can read at
GET /v1/banking/payouts/{id} (rail: "fx_conversion") — the id is in the
message of a replayed request, below.
Retries: different from payouts
Idempotency-Key is required, and it stops a conversion from running twice —
but a replay does not return the first response:
| You send | You get |
|---|---|
| The same key and the same body | 409 conflict — "this Idempotency-Key was already used for conversion 6f1e… (status: settled)" |
| The same key and a different body | 409 idempotency_key_reuse |
| A new key with a quote already executed | 400 quote_consumed (sandbox) |
So when a conversion request times out and you retry with the same key, a
409 conflict is not a failure: the message carries the conversion's id and
the status of your first attempt. In the sandbox that is settled, or
failed if the first attempt was refused. In production it is submitted if
the partner accepted it, and created if the outcome is not known yet — the
attempt is still running, or the partner call failed or timed out, and the
reconciler is matching it against the partner's own notifications. The table
in Idempotency
says what to do with each. Do not retry with a new key until you know the
first attempt failed: a new quote is a new conversion.
Keys are shared with payouts: a key you used for a payout cannot be used here.
Errors
| Error | When |
|---|---|
400 account_currency_mismatch | account_id does not hold the quote's sell currency |
400 quote_expired | The quote's expires_at has passed (sandbox) |
400 quote_consumed | The quote was already executed (sandbox) |
400 insufficient_balance | Not enough money in account_id (sandbox) |
400 bad_request | Production: the partner rejected the request outright, for any of the reasons above or another; message has its reason. A conversion it accepts and cannot carry out turns failed later instead. |
403 account_not_verified | Your account is not verified — see KYB |
403 permission_denied | A dashboard session whose seat lacks fx.execute (viewer, api_consumer) |
409 quote_unrecorded | We hold no record of this quote. Dashboard sessions only — get a new quote. |
409 conflict / 409 idempotency_key_reuse | See retries above |
401 two_factor_required and the other step-up codes | A dashboard session converting 10,000 USD or more — keys never step up |
A conversion is stopped by a platform lockdown like a payout.
Locking a rate for a payout
To pay a beneficiary in a currency your source account does not hold, quote
with "fixed_side": "buy" and the payout amount, then pass the quote_id as
the payout's fx_quote_id. The full rules are in
Payouts.