Skip to main content

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 until expires_at. Executing it converts money between two of your accounts, or it can lock the rate for a payout in another currency.

Indicative rates​

HTTP
GET /v1/fx/rates?base=EUR&currencies=USD,GBP
JSON
{
"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"
}
  • rate is units of base per one unit of currency: above, 1 USD is 0.92 EUR.
  • base and every entry in currencies are three-letter codes; at most 30 currencies per call.
  • A currency that cannot be priced goes into unavailable rather than failing the call. In production that happens before your account is approved.
  • rate_source says what kind of price it is, not who supplied it: live when the rail that would execute the trade priced it, reference when 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​

HTTP
POST /v1/banking/fx/quote
Content-Type: application/json

{"sell_currency": "EUR", "buy_currency": "GBP", "amount": "20.00"}
FieldRequiredNotes
sell_currencyYesWhat leaves your account
buy_currencyYesWhat arrives
amountYesDecimal string
fixed_sidesell (default): sell exactly amount. buy: deliver exactly amount.

200:

JSON
{
"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_amount is what is debited, any unit fee included; buy_amount is what is credited.
  • rate_source is live when the rail that would execute the conversion priced it — always, in production — and reference when the sandbox's ECB table did, which is an indication rather than a price. The rail is not named.
  • rate is the rate you get, after our margin. tier is that margin as a percentage ("1.00" is 1%).
  • unit_fee and unit_fee_currency appear when a fixed fee applies.
  • expires_at is the only lifetime to trust. Sandbox quotes last 15 minutes; production quotes last as long as our partner holds the price, which varies.
  • tradable: false is 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.

ErrorWhen
400 bad_requestfixed_side not sell/buy; same currency on both sides; amount zero, negative or too precise
422 corridor_unsupportedNobody 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_closedThe pair is outside its market hours; message is written to be shown to the payer
503 service_unavailableThe rail that offers the pair is down — retry with backoff

Converting between your accounts​

HTTP
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:

JSON
{
"deal_id": "sandbox_fx_6f1e3a9c2b7d4e5f8a0c1d2e3f4a5b6c",
"status": "settled",
"raw": {"simulated": true, "quote_id": "mockfx_…"}
}
  • Sandbox: booked and settled in the response.
  • Production: status: "booked"; it settles later, as the partner reports. raw carries the partner's execution response under response, next to the quoteId and 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 sendYou get
The same key and the same body409 conflict — "this Idempotency-Key was already used for conversion 6f1e… (status: settled)"
The same key and a different body409 idempotency_key_reuse
A new key with a quote already executed400 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​

ErrorWhen
400 account_currency_mismatchaccount_id does not hold the quote's sell currency
400 quote_expiredThe quote's expires_at has passed (sandbox)
400 quote_consumedThe quote was already executed (sandbox)
400 insufficient_balanceNot enough money in account_id (sandbox)
400 bad_requestProduction: 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_verifiedYour account is not verified — see KYB
403 permission_deniedA dashboard session whose seat lacks fx.execute (viewer, api_consumer)
409 quote_unrecordedWe hold no record of this quote. Dashboard sessions only — get a new quote.
409 conflict / 409 idempotency_key_reuseSee retries above
401 two_factor_required and the other step-up codesA 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.