Quotes and the route lock
POST /v1/banking/payouts/quote takes the body of the payout you intend to
send and answers three things: whether our checks pass, what the provider's
rate says arrives, and the route the payout would take — locked under a
quote_id. A payout sent with that quote_id runs on exactly that route or
is refused. It is never rerouted silently. Every payout has a route — a bank
payout to a beneficiary too — and this page describes them all, with the
digital-asset sends in detail.
The quote moves nothing and records nothing: no step-up, no
Idempotency-Key, and asking twice costs nothing. Key scope payments:read.
It runs the send's own checks, short of the travel rule's completeness, the
caps and who is asking. An API key's quote names a trusted beneficiary as its
send would (400 trusted_beneficiary_required otherwise), for every
destination but a swap.
curl -s -X POST "$CXC_BASE/v1/banking/payouts/quote" \
-H "Authorization: Bearer $CXC_KEY" \
-H "Content-Type: application/json" \
-d '{
"source_account_id": "'"$USDC"'",
"source_network": "solana",
"amount": "5.00",
"currency": "USDC",
"destination": {"type": "onchain", "trusted_beneficiary_id": "'"$TRUSTED"'"}
}'
What a quote answers
On chain, across chains, and by @handle:
{
"amount": "5.00",
"currency": "USDC",
"network": "solana",
"destination_network": "base",
"minimum": "1",
"fees": null,
"network_fee": null,
"valid": true,
"notes": ["checked against our own rules: …"],
"quote_id": "b454c169-6786-42a0-a455-77aeea7bce3d",
"expires_at": "2026-11-03T16:15:00Z",
"route": { "…": "…" },
"rate_locked": false
}
| Field | |
|---|---|
network | The chain of the wallet it leaves |
destination_network | The chain the address is on. Absent by @handle: which chain the recipient's wallet is on is never shown |
minimum | The provider's minimum for this send, in the asset |
fees, network_fee | null, not zero: the provider charges when the send executes, and no fee figure is returned for an on-chain send |
valid, notes | The verdict and why — below |
valid. By default it is our own checks' verdict — the asset on the
chain, the address, the travel rule's shape, the minimum — and the provider
is not asked. Where the deployment runs the provider's dry run of an
on-chain send, valid and notes are the provider's instead:
-
valid: trueorfalse— the provider checked the send and gave its verdict;notesare its own. -
valid: false— the provider refused the send. Where the refusal names something to change, the note gives its reason: the field and its message, for example a Stellar account with no trust line for USDC:JSON{ "valid": false,
"notes": ["the provider's check refused this send (destination_stellar_account): The asset USDC is not added to the Stellar account. Please create a trust line for USDC"] }Any other refusal says to contact support.
-
valid: null— the dry run did not answer (a timeout, an outage, a throttle), with a note.
The API refuses no payout on valid. Fix what a refusal names before you
send: sent unchanged, the send is most likely refused again. A refusal at
that point is normally 422 destination_not_payable (provider_refused): the
payout failed, its Idempotency-Key spent. If the create had to be retried,
it is 503 payout_outcome_unknown instead: send again only under the same
key. See Sandbox behaviour.
By @handle, valid is our own checks' (the recipient's wallet, the route, the
minimum).
To an IBAN, to a bank account outside SEPA, and a swap:
{
"source_currency": "USDC",
"amount": "10.00",
"destination_currency": "EUR",
"destination_amount_estimate": "9.15",
"rate": "0.915",
"rate_pair": "USD/EUR",
"rate_as_of": "2026-11-03T09:05:30Z",
"indicative": true,
"fees": null,
"minimum": "2",
"quote_id": "…",
"expires_at": "…",
"route": { "…": "…" },
"rate_locked": false
}
amount leaves exactly. destination_amount_estimate is what the provider's
rate says arrives — EUR to an IBAN; USD, GBP, MXN or BRL to a bank
account; the asset bought by a swap — truncated to the cent and never
locked. rate prices the fiat the assets are pegged to (rate_pair
USD/EUR from USDC, EUR/USD from EURC into USDC), never an asset. EURC to
an IBAN is one for one (EUR/EUR). The sandbox's rate can be months old:
show rate_as_of.
Send-back (USDC to your own EUR account): source_amount_estimate, and
max_source_amount — what the USDC account must have free for the send-back,
and holds while it is in flight and for about a minute after it finishes: the
estimate, the provider's 1 % buffer on a payout whose arriving amount is
fixed, and a margin for the rate moving. rate is the provider's USD/EUR
rate.
Convert (EUR into your USDC account): destination_amount_estimate in
USDC, and rate with rate_pair EUR/USD.
The route
"route": {
"kind": "onchain",
"source": { "type": "account", "id": "…", "currency": "USDC", "country": null,
"network": "solana", "account_type": null },
"destination": { "type": "trusted_beneficiary", "id": "6a0f5a8e-…", "currency": "USDC",
"country": null, "network": "base", "account_type": null },
"steps": [
{ "type": "onchain_transfer", "asset": "USDC", "network": "base", "by": "casp" }
],
"settlement_asset": "USDC",
"rate": "none",
"simulated": false
}
A route says what the payout does, step by step — never who does it. No
provider's name or id appears in a route, a quote or a payout. The party
taking a step is named by kind: bank (the bank holding the source account),
casp (the crypto-asset service provider holding the digital-asset account)
or sandbox (the sandbox's own ledger: nothing is sent).
kind | Steps |
|---|---|
bank_payout | A payout to a beneficiary from a bank account: (conversion at the rate of the fx_quote_id it carries, from an account in another currency), bank_transfer over the rail sent — null where the bank picks |
onchain | onchain_transfer of the asset on the chain the address is on — the wallet's own, or another across chains |
iban | conversion asset → EUR (floating), bank_transfer sepa to the IBAN's country |
bank | conversion asset → USD, GBP, MXN or BRL (floating), bank_transfer over ach or fedwire, fps, spei or pix, to US, GB, MX or BR |
internal | internal_transfer of the asset into a wallet of the recipient's — which one, and its chain, never shown |
swap | conversion asset → asset on the wallet's chain (floating) |
send_back | conversion USDC → EUR (floating), bank_transfer sepa to your account's IBAN country |
convert | bank_transfer sepa EUR to the USDC account's IBAN country (bank, or sandbox from a sandbox account), conversion EUR → USDC (floating) |
destination.type is beneficiary, own_account, onchain_address, iban,
bank_account, cxc_handle or trusted_beneficiary; its id is what you
named (null for an address, IBAN, bank account or handle given inline).
rate is none (no conversion), floating (the provider's rate when it
executes) or fx_quote (the rate of the FX quote a bank payout carries).
simulated is true where the sandbox's own ledger stands in for a bank.
The lock: quote_id
| Field | |
|---|---|
quote_id | Send it with the payout to hold it to route. Single use |
expires_at | When the lock ends: 15 minutes after the quote unless the deployment sets otherwise (route_lock.ttl_seconds in Capabilities) |
rate_locked | Always false — the lock holds the route, never a rate |
curl -s -X POST "$CXC_BASE/v1/banking/payouts" \
-H "Authorization: Bearer $CXC_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: locked-send-$RUN" \
-d '{
"source_account_id": "'"$USDC"'",
"source_network": "solana",
"amount": "5.00",
"currency": "USDC",
"destination": {"type": "onchain", "trusted_beneficiary_id": "'"$TRUSTED"'"},
"quote_id": "'"$QUOTE_ID"'"
}'
What must match. The payout repeats the quote's source_account_id, its
destination, amount (compared as a number: 5 is 5.00), currency,
rail, destination_country, source_network and destination_network.
The destination is compared by what it is: the address with its chain and
memo; the IBAN however it is written, with its BIC; a bank account by its
kind, coordinates and rail (a US account's rail left out is ach); a
@handle and the customer it names; a trusted beneficiary as named — a trusted
beneficiary and its address given inline are two destinations. reference,
the travel-rule beneficiary and an account's holder are not part of the
route and may differ.
Before anything is recorded or a step-up spent, the payout checks that the
quote is yours, unexpired, unspent and quoted for this body, then decides its
route as the quote did and compares the two. The answer carries quote_id
and route; so does a replay.
| Status | error | When |
|---|---|---|
400 | bad_request | quote_id is empty |
404 | not_found | No quote of yours has this id |
422 | route_quote_expired | After expires_at (details.quote_id, details.expired_at). Ask for a new quote |
409 | route_quote_used | The quote was spent on another payout, under another Idempotency-Key |
422 | route_quote_mismatch | The body is not the one quoted; details.fields names what differs |
409 | route_changed | The route the payout would take now is another; details.quoted_route and details.current_route. Nothing was sent. Ask for a new quote |
None of these spends the quote. A quote is spent on the payout recorded
under it; the same key and body replay that payout (200), after
expires_at too. Without quote_id, a payout is routed as it always was.