Skip to main content

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.

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

JSON
{
"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
networkThe chain of the wallet it leaves
destination_networkThe chain the address is on. Absent by @handle: which chain the recipient's wallet is on is never shown
minimumThe provider's minimum for this send, in the asset
fees, network_feenull, not zero: the provider charges when the send executes, and no fee figure is returned for an on-chain send
valid, notesThe 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: true or false — the provider checked the send and gave its verdict; notes are 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:

JSON
{
"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​

JSON
"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).

kindSteps
bank_payoutA 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
onchainonchain_transfer of the asset on the chain the address is on — the wallet's own, or another across chains
ibanconversion asset → EUR (floating), bank_transfer sepa to the IBAN's country
bankconversion asset → USD, GBP, MXN or BRL (floating), bank_transfer over ach or fedwire, fps, spei or pix, to US, GB, MX or BR
internalinternal_transfer of the asset into a wallet of the recipient's — which one, and its chain, never shown
swapconversion asset → asset on the wallet's chain (floating)
send_backconversion USDC → EUR (floating), bank_transfer sepa to your account's IBAN country
convertbank_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_idSend it with the payout to hold it to route. Single use
expires_atWhen the lock ends: 15 minutes after the quote unless the deployment sets otherwise (route_lock.ttl_seconds in Capabilities)
rate_lockedAlways false — the lock holds the route, never a rate
Bash
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.

StatuserrorWhen
400bad_requestquote_id is empty
404not_foundNo quote of yours has this id
422route_quote_expiredAfter expires_at (details.quote_id, details.expired_at). Ask for a new quote
409route_quote_usedThe quote was spent on another payout, under another Idempotency-Key
422route_quote_mismatchThe body is not the one quoted; details.fields names what differs
409route_changedThe 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.