Stablecoin transfers with an API key
This guide is for an integration that moves stablecoins with an API key. It covers two flows, each as the calls you make, in order:
- Wallet to wallet — USDC from your account's wallet to a wallet that a person of your account approved.
- Bank to wallet — EUR into your USDC account, then on to an approved wallet; and what does not exist.
Stablecoin sends to third-party wallets exist only in the sandbox today, at
https://api.sandbox.cashxchain.com. Production offers no stablecoin send of
any kind, and no digital-asset account or wallet: there, onboarding answers
503 digital_assets_unavailable, opening a USDC account
422 currency_unavailable, a send 422 corridor_unsupported, and the sandbox
routes 404. In the sandbox nothing real moves, on any chain or bank.
What a send is. Your digital-asset accounts and their wallets are held at
our crypto-asset service provider; CashXChain holds no private keys. A send
is an instruction: CashXChain asks the provider to pay the amount out of your
wallet's balance to the destination, and records what happens. A send on
chain is requested with the chain of the wallet it leaves (source_network);
the payout reads that chain as network, and the chain it is paid to as
destination_network. The API never names an address the transfer is sent
from.
Before you start
| You need | Where | |
|---|---|---|
| A sandbox account | auth.sandbox.cashxchain.com | Separate from production: its keys and data are unknown there |
| A person with two-factor authentication | That person's sign-in: an authenticator app or a passkey, set up in the web app's security settings | Every recipient an API key pays is approved by a person with their second factor. The sandbox has no bypass |
| A secret key | dev.sandbox.cashxchain.com → API keys → Create key | Created by a person whose seat holds api_keys.manage (an owner or admin). The token is shown once. A restricted key (cxc_rk_) works too, with accounts:read, accounts:write, payments:read and payments:write. See Managing API keys |
curl and jq | Your shell | The examples below use both |
Plans. In the sandbox the API is open on every plan, the Free plan
included: secret keys, webhooks and every route on this page. A plan's
limits still apply; for this guide, its number of currencies — all digital
assets together count as one — and its payment limits on a convert and on a
send-back to your EUR account, measured on the EUR
(422 plan_limit_exceeded, see
Plan limits). Stablecoin sends do
not count against those; they have caps of their own, per asset. Seats count
in the sandbox too: on a one-seat plan, the person who creates the key is the
one who approves its recipients.
The web app. A person approves recipients on the Digital Assets page, app.sandbox.cashxchain.com/digital-assets. On the Free plan the navigation shows that entry greyed, labelled Starter, and opens an upgrade dialog; the page itself opens at its address on every plan, and approving a recipient there needs no upgrade.
Verification. In the sandbox a business verification is approved when it
is referred, and an account still pending may already do everything here —
see KYB. Your business's country, the one your account was
opened with, decides some routes: a business in the EEA is offered no GBP,
MXN or BRL payout and no USDT across chains
(Regions).
export CXC_BASE=https://api.sandbox.cashxchain.com
export CXC_KEY=cxc_sk_sandbox_... # your secret key
export RUN=$(date +%s) # makes this run's Idempotency-Keys unique
Wallet to wallet
1. The key and the Idempotency-Key
Authorization: Bearer $CXC_KEYon every request. A key needs no step-up, and sends to a third party only to a trusted beneficiary.Idempotency-Keyon every write below but the quote, the sandbox approval,simulate-completionand the webhook registration, which take none. Take it from your own record of the operation, and keep it across retries. What a repeat does depends on the route:- a payout or the faucet: the same key and body answer what the first
request did — a payout
200, as it stands now — and another body under the key is409 idempotency_key_reuse; - opening an account: a repeat is
409 conflict(you already hold it), and another currency under the same key opens that currency; - opening a wallet: the key is not used — the wallet is yours on that
chain,
201the first time and200after; - test funds: another amount or chain under the same key is a new deposit (Test funds).
- a payout or the faucet: the same key and body answer what the first
request did — a payout
- After a refusal, look for
details.idempotency_key_spent. Without it, nothing was recorded: correct the request and send it again under the same key. With it, the send was recorded and isfailed(or, after503 payout_outcome_unknown, being confirmed), and a new send takes a new key — see Was anything recorded?
See Idempotency and Authentication.
2. Capabilities
curl -s "$CXC_BASE/v1/banking/capabilities" \
-H "Authorization: Bearer $CXC_KEY" \
| jq '{environment, flows: [.flows[] | {flow, status, reason}], digital_rules, route_lock, account}'
| Read | |
|---|---|
environment | sandbox |
flows | stablecoin_to_stablecoin and cross_chain read sandbox_only — or requires_onboarding, with the step in reason, until step 3 is done |
digital_rules | api_keys_send_only_to_trusted_beneficiaries: true, and trusted_beneficiary_cooldown_seconds — 0 by default in the sandbox |
route_lock | ttl_seconds — how long a quote's route lock holds (900 unless the deployment sets otherwise) |
digital_assets | Per asset offered: the chains it is sent from and to, for your business's region, and its caps per send, day and month (limits) |
account | verified (may move money), digital_asset_account (holds an open USDC account), digital_assets_held |
actions | Every banking route, and whether the route policy admits this key (allowed_by_policy) |
Capabilities say what is offered, not that a given request will be accepted. Every field: Capabilities.
3. A USDC account and its wallets
Approve your business at the provider's sandbox, then open the account. The
approval takes no body and no Idempotency-Key, and calling it again is
harmless (at most 30 calls an hour). Unless you completed a hosted
verification, the approval makes and approves a test customer at the
provider, and in the sandbox that test customer — not your business — is the
originator the provider records on your sends
(Onboarding).
curl -s -X POST "$CXC_BASE/v1/sandbox/banking/kyb/digital-assets/approve" \
-H "Authorization: Bearer $CXC_KEY" | jq '{state, can_open_account, rails}'
curl -s -X POST "$CXC_BASE/v1/banking/accounts" \
-H "Authorization: Bearer $CXC_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: open-usdc-$RUN" \
-d '{"currency": "USDC"}' | jq '{currency_account, error, message}'
USDC=$(curl -s "$CXC_BASE/v1/banking/accounts" \
-H "Authorization: Bearer $CXC_KEY" \
| jq -r '.accounts[] | select(.currency == "USDC") | .id')
echo "$USDC"
201with the account, which opens with a wallet on Solana.409 conflict: you already hold one — carry on with its id.409 digital_assets_onboarding_required: the approval has not gone through.- One account per asset; EURC and the others open the same way. All of them together count as one currency of your plan.
- In
GET /v1/banking/accountsthe account lists itsidentifiers— the EUR IBAN first, then oneonchainidentifier (network,asset,address) per wallet — anddigital_actions: the chains it sends from (send_onchain) and the chains an address may be on (send_onchain_to). See Accounts and wallets.
A wallet per chain, shared by your asset accounts:
curl -s "$CXC_BASE/v1/banking/digital-assets/wallets" \
-H "Authorization: Bearer $CXC_KEY" \
| jq '{wallets: [.wallets[] | {network, address, balances}], networks}'
curl -s -X POST "$CXC_BASE/v1/banking/digital-assets/wallets" \
-H "Authorization: Bearer $CXC_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: wallet-base-$RUN" \
-d '{"network": "base"}' | jq
201 with the new wallet, or 200 with the one you already hold on that
chain. The chains and the assets on each are in the
overview.
4. Test funds
curl -s -X POST "$CXC_BASE/v1/sandbox/banking/accounts/$USDC/simulate-deposit" \
-H "Authorization: Bearer $CXC_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: test-funds-$RUN" \
-d '{"amount": "100", "network": "solana"}' | jq
curl -s "$CXC_BASE/v1/banking/accounts/$USDC/balance" \
-H "Authorization: Bearer $CXC_KEY" | jq '.balances[] | {available, by_network}'
202 — {"account_id", "currency", "network", "amount", "status": "simulated"}.
The deposit is booked usually within five seconds, at most five minutes: the
balance, an onchain_transfer on GET /v1/transactions, and a
payin.received webhook. At most 100,000 a request with six decimal places,
and 30 requests an hour per account. Test funds are how a sandbox wallet is
funded: no asset sent on chain to a sandbox address arrives. See
Test funds.
5. A trusted beneficiary
An API key pays a third party only to a trusted beneficiary: a destination a person of your account approved with their second factor. The key names the entry; where the money goes is read from it.
| An API key | A person, in the web app | |
|---|---|---|
| List the entries | GET /v1/banking/digital-assets/trusted-beneficiaries (accounts:read) | Yes |
| Send to an entry | Yes, by its id, no step-up | Yes, with a step-up |
| Send to an address no one approved | Never — 400 trusted_beneficiary_required | Yes, with a step-up |
| Approve or revoke an entry | Never — 403 session_required, master keys included | Yes, with a step-up; the seat needs payouts.create |
A person approves it:
- Sign in at app.sandbox.cashxchain.com and open Digital Assets.
- On the Approved recipients card choose Manage, then Approve a recipient.
- Give the recipient a name, and enter the chain, the address and who holds the wallet. A third party needs a name, street, city and country, a business also its identifier (LEI, VAT or company registration), and the wallet's type: at an exchange or another custodian, or self-custodied. Your own wallet elsewhere needs only that it is yours, and its type.
- Confirm with the second factor. The step-up is bound to that destination alone.
The address may not be one of your own wallets: that is refused
422 destination_is_own_wallet. To test, use a wallet address of another
sandbox account, or any valid address on that chain — in the sandbox nothing
is sent on chain.
Each destination is approved on its own. An entry is one chain and one
address (with a memo on Stellar); it names no asset — a send to it gives its
currency, as any send does. Its chain may be one of your wallets' chains or
a chain the asset is sent to across chains (digital_actions.send_onchain_to).
The fields, and the other kinds of entry: API keys and trusted
beneficiaries.
The cooldown. How long after an approval before a key may use the entry:
digital_rules.trusted_beneficiary_cooldown_seconds in
capabilities. By default it is 0 in the sandbox, so an
entry is usable at once, and at least 24 hours in production, which sends
nothing to third parties. While it runs, the entry reads cooling_down and a
key's send is 422 beneficiary_not_trusted with
details.api_keys_usable_from.
Read the entry's id with the key:
curl -s "$CXC_BASE/v1/banking/digital-assets/trusted-beneficiaries" \
-H "Authorization: Bearer $CXC_KEY" \
| jq '.trusted_beneficiaries[] | {id, kind, label, network, address, status, api_keys_usable_from}'
TRUSTED=$(curl -s "$CXC_BASE/v1/banking/digital-assets/trusted-beneficiaries" \
-H "Authorization: Bearer $CXC_KEY" \
| jq -r '[.trusted_beneficiaries[] | select(.kind == "onchain" and .status == "active")][0].id')
echo "$TRUSTED"
- Revoking is a person's, with a step-up, and final. A send whose hold on the wallet was written before the revocation can still complete: to stop a key's sends at once, revoke the key as well.
- Your own self-custodied wallets may receive at most 1,000 of each asset
(USDC, EURC) over a rolling 30 days, this send included:
422 wallet_attestation_required. Wallets of yours at a custodian, and third parties' wallets, are not counted.
6. Quote and lock the route
A quote moves nothing and records no payout; it stores only the route lock
that quote_id names. No Idempotency-Key, no step-up, key scope
payments:read. From a key, destination carries type and
trusted_beneficiary_id and nothing else.
QUOTE=$(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"'"}
}')
echo "$QUOTE" | jq '{network, destination_network, minimum, fees, network_fee, valid, notes, quote_id, expires_at, rate_locked, route}'
QUOTE_ID=$(echo "$QUOTE" | jq -r '.quote_id')
| Field | |
|---|---|
network, destination_network | The chain of the wallet it leaves, and the entry's chain |
minimum | The provider's minimum for this send, in the asset: 1 USDC on chain |
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 | Our own checks' verdict; where the deployment runs the provider's dry run, the provider's. The API refuses no payout on valid: fix what a refusal names before you send |
quote_id, expires_at | The route lock: single use, until expires_at — 15 minutes unless the deployment sets otherwise |
route | What the payout will do: kind onchain, one step onchain_transfer of the asset on the entry's chain, by casp. No provider is named |
rate_locked | Always false: the lock holds the route, never a rate |
See Quotes and the route lock.
7. Send
PAYOUT=$(curl -s -X POST "$CXC_BASE/v1/banking/payouts" \
-H "Authorization: Bearer $CXC_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 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"'"
}')
echo "$PAYOUT" | jq '{id, status, kind, network, destination_network, amount, currency, quote_id}'
PAYOUT_ID=$(echo "$PAYOUT" | jq -r '.id')
202 Accepted,statussubmitted,kindonchain. Key scopepayments:write; no step-up.amountis what leaves the wallet, exactly, with at most two decimal places; the payout reads it back at the asset's six ("5.000000").- The wallet must cover the amount plus its chain's headroom — 1 %, 5 % on Ethereum — less what your other sends from it hold (Holds).
- Under
quote_idthe body must repeat what was quoted (422 route_quote_mismatchnames what differs), and the payout runs on the quoted route or is refused (409 route_changed: nothing sent, quote again). Withoutquote_idit is routed as usual. - Keep
quote_idandroutefrom the202:GET /v1/banking/payoutsandGET /v1/banking/payouts/{payout_id}do not return them. - Across chains: an entry on another chain than
source_networkgets the same asset on its chain — never a swap — where the provider routes it (Across chains).
8. Follow it to settled
There is no webhook for a payout's status: poll it.
for i in $(seq 1 40); do
STATUS=$(curl -s "$CXC_BASE/v1/banking/payouts/$PAYOUT_ID" \
-H "Authorization: Bearer $CXC_KEY" | jq -r '.status')
echo "$(date +%T) $STATUS"
case "$STATUS" in settled|failed|cancelled|returned|needs_reconciliation) break ;; esac
sleep 15
done
curl -s "$CXC_BASE/v1/banking/payouts/$PAYOUT_ID" \
-H "Authorization: Bearer $CXC_KEY" | jq '{status, tx_hash, settled_at, simulated}'
- Statuses:
submitted,processing, thensettled— orfailed,cancelled,returned. Two more can appear:created, while a send is being confirmed after503 payout_outcome_unknown; andneeds_reconciliation, when the provider reports a state only a person can resolve — support resolves it, and it then readssubmitted,processing,settled,failedorcancelled. On chain,tx_hashappears once the provider reports the send completed. In the sandbox neither atx_hashnor an address is on any public chain: neither can be looked up on an explorer. See the lifecycle. - Pace. The sandbox reads a send's status from the provider every 15 seconds for its first ten minutes — up to 10 payouts a read across the whole sandbox, so with more in flight each is read every few reads — and about every two minutes while it is in flight. Polling every 15 seconds keeps up; polling counts against your 600 requests a minute (Rate limits).
- Lists.
GET /v1/banking/payoutslists your payouts newest first, withhas_moreandnext_cursor(sent back asbefore) — see Listing. - Webhooks. The only money event is
payin.received, for each credit booked on your accounts — test funds, a convert's USDC, a send to you by @handle, a swap's bought asset. No event reports a payout. Register an endpoint withPOST /v1/webhooksand{"url": "…", "events": ["payin.received"]}, with a secret key or a restricted key withaccounts:write(Webhooks).
9. Sandbox timings
The public sandbox receives no webhooks from the provider: these timings come from the sandbox's own reads, and when the provider's sandbox completes a send or takes its money, it does not say in advance.
| What | When, in the sandbox |
|---|---|
| Test funds | Booked usually within five seconds, at most five minutes |
| A send on chain, across chains or by @handle | Completed by the provider's sandbox — in our runs from about 25 seconds to a little over a minute after it accepted the send, and in one run between about 45 seconds and 3½ minutes — and settled at the next 15-second read of its status |
A send to an IBAN, a gb_bank, clabe or pix account, a send-back, a swap | Left in flight by the provider's sandbox, and settled by ours (simulated: true) once the money has been seen leaving the wallet: with the default one-minute wait, 60 to 75 seconds after acceptance where the provider has listed its debit by about 45 seconds, otherwise within about 15 seconds of the first read of the wallet after it is listed |
A send to a us_bank account | Left to the provider's sandbox for five minutes; one it completes reads settled with no simulated. Still in flight then, it is settled by ours: by default 300 to 315 seconds after acceptance where its debit is listed by about 285 seconds |
simulate-completion on one of the sends ours settles | At once, whatever its wait, once its debit has been seen — before that, 409 not_simulatable (debit_not_seen): ask again in a few seconds |
| A convert from your sandbox EUR account | settled in the 202; the USDC is booked within seconds |
The reads behind them, and every case, are in
When a send reads settled.
10. Errors
Every error has one envelope:
{"success": false, "error": "<code>", "message": "…", "details": {…}}.
Branch on error and details. The ones this flow meets:
| Status | error | When | What to do |
|---|---|---|---|
401 | unauthorized | The key is unknown or revoked, or from the other environment | Check the key and CXC_BASE |
403 | insufficient_scope | A restricted key without the route's scope (details.required_scope) | Use a key that holds it |
403 | session_required | A key approving or revoking an entry; a key acting for another account (Cxc-Account) sending to a third party | A person approves entries in the web app; send with the account's own key |
409 | digital_assets_onboarding_required | Opening an account or a wallet, or sending, before the approval has gone through (on opening, details.state and details.next_steps) | Step 3 |
403 | currency_limit_reached | The plan's currencies are used up | Close an account, or upgrade |
409 | wallet_not_open | No wallet of yours on that chain (source_network, or a deposit's network) | Open one, step 3 |
400 | trusted_beneficiary_required | The key sent an address, network, memo or beneficiary in destination, or no trusted_beneficiary_id. Nothing recorded | Send the entry's id alone |
400 | bad_request | type is not the entry's kind; more than two decimal places; currency not the account's asset; no source_network; a reference on chain | Correct it, same key |
404 | not_found | No entry, account or quote of yours has that id | Check the id |
422 | beneficiary_not_trusted | details.reason: revoked, cooling_down (details.api_keys_usable_from) | Revoked: a person approves the destination anew. Cooling down: wait until api_keys_usable_from |
422 | amount_below_minimum | Under the provider's minimum (details.minimum): 1 of the asset on chain | Send more |
422 | asset_not_on_network | The chain does not carry the asset here | Another chain |
422 | corridor_unsupported | Not a route offered here (details.corridor): sends off, production, a chain the asset is not sent to, USDT across chains for a business in the EEA | Check digital_actions |
422 | wallet_attestation_required | Past 1,000 of the asset to your own self-custodied wallets in 30 days (details.sent, details.remaining) | Wait for the window |
422 | digital_send_limit_exceeded | Over a per-asset cap — by default in the sandbox 10,000 a send, 50,000 a day, 250,000 a month; a deployment's caps are digital_assets[].limits in capabilities (details.cap, used, remaining) | Send less |
422 | route_quote_expired | After expires_at | Quote again |
422 | route_quote_mismatch | The body is not the one quoted (details.fields) | Send the quoted body |
409 | route_quote_used | The quote was spent on another payout | Quote again |
409 | route_changed | The route would be another now (details.quoted_route, details.current_route). Nothing sent | Quote again |
409 | earlier_payout_unconfirmed | An earlier send of yours is still being confirmed (details.payout_id). Nothing recorded | Same key, once that payout reads submitted or failed |
409 | idempotency_key_reuse | The key was used for another body | A new key for a new send |
400 | insufficient_balance | Recorded. The wallet, less what your other sends hold, does not cover the amount and its headroom; the message names both | A new key, once enough is free |
422 | destination_not_payable | Recorded, failed, nothing held. details.reason provider_refused: the provider refused the transfer when it was created (details.provider_field names a field it refused); not_trusted: the entry was revoked before the send's hold was written | Change what it names, then a new key; for a refusal in our words, contact support first |
503 | payout_outcome_unknown | Recorded. The provider did not confirm the send | Send it again only under the same key: it reads submitted once the provider is found to hold it, or failed after seven minutes or more |
503 | service_unavailable | The provider is unavailable. Nothing recorded, unless details.idempotency_key_spent | Retry with backoff, same key — or a new key if it was recorded |
429 | rate_limited | Over 600 requests a minute for the key; a sandbox tool past 30 an hour | Wait Retry-After, or the minutes the message names |
Every code, and every route's: Digital-asset errors and the error reference.
Bank to wallet
The way from a bank account to a wallet is two legs, each its own request: EUR into your USDC account, where it is held as USDC in your own wallet; then a send from that wallet to a trusted beneficiary, as in steps 5 to 8. Have steps 1 to 3 done first.
1. The fiat pay-in details
The USDC account has an EUR IBAN of its own. EUR paid to it by SEPA arrives
as USDC in the account's wallet on Solana — the provider converts it at its
rate when it arrives, less its fees — and is booked as a credit, with
payin.received.
curl -s "$CXC_BASE/v1/banking/accounts/$USDC/payin-instructions" \
-H "Authorization: Bearer $CXC_KEY" \
| jq '.instructions[] | {rail, currency, account_holder_name, beneficiary_is_account_holder, notice, identifier}'
- The SEPA instruction comes first:
railsepa,currencyEUR, the IBAN and BIC inidentifier, andaccount_holder_name— the IBAN's holder exactly as the provider states it. Where that is not your company's name,beneficiary_is_account_holderisfalseandnoticetells the payer to enter the name as shown. - Then one on-chain instruction per wallet that carries the asset:
railthe chain,currencyUSDC,identifier{"scheme": "onchain", "network", "asset", "address"}. - In the sandbox nothing reaches these details: no SEPA transfer and no on-chain transfer is credited, and EUR to the IBAN cannot be simulated — the provider's sandbox refuses it. Use step 2.
2. Money in, in the sandbox
Two ways, depending on what you test:
- The bank leg as well: fund your sandbox EUR account with the faucet, then convert it.
- The wallet leg alone: test funds straight into the USDC wallet, as in step 4 above.
# Outside the euro area a business's first account is not EUR: open it first.
# It takes one of your plan's currencies; 409 conflict if you hold it already.
curl -s -X POST "$CXC_BASE/v1/banking/accounts" \
-H "Authorization: Bearer $CXC_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: open-eur-$RUN" \
-d '{"currency": "EUR"}' | jq '{currency_account, error, message}'
EUR=$(curl -s "$CXC_BASE/v1/banking/accounts" \
-H "Authorization: Bearer $CXC_KEY" \
| jq -r '.accounts[] | select(.currency == "EUR") | .id')
echo "$EUR"
curl -s -X POST "$CXC_BASE/v1/sandbox/banking/faucet" \
-H "Authorization: Bearer $CXC_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: faucet-eur-$RUN" \
-d '{"currency": "EUR", "amount": 100}' | jq
200 — {"currency": "EUR", "amount": "100", "balance": "…"}. amount is
whole euros, 1 to 100,000; with an Idempotency-Key it is credited once.
409 when you hold no EUR account: open one with POST /v1/banking/accounts
and {"currency": "EUR"}. At most 30 requests an hour. See
Test money.
3. Convert EUR into USDC
A payout from your EUR account to your USDC account. It is offered
where the EUR account's own_account_payouts reads ["EUR"]. Where this
deployment does not convert, the convert method of fiat_to_stablecoin in
GET /v1/banking/capabilities reads not_offered, with its reason, and a
convert answers 422 corridor_unsupported: fund the wallet with test funds
instead.
curl -s "$CXC_BASE/v1/banking/accounts" -H "Authorization: Bearer $CXC_KEY" \
| jq '.accounts[] | select(.currency == "EUR") | {id, own_account_payouts}'
# The estimate: nothing moves
curl -s -X POST "$CXC_BASE/v1/banking/payouts/quote" \
-H "Authorization: Bearer $CXC_KEY" \
-H "Content-Type: application/json" \
-d '{"source_account_id": "'"$EUR"'", "destination_account_id": "'"$USDC"'",
"amount": "20.00", "currency": "EUR"}' \
| jq '{destination_amount_estimate, rate, rate_pair, rate_as_of, indicative, fees}'
# The convert
curl -s -X POST "$CXC_BASE/v1/banking/payouts" \
-H "Authorization: Bearer $CXC_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: convert-$RUN" \
-d '{"source_account_id": "'"$EUR"'", "destination_account_id": "'"$USDC"'",
"amount": "20.00", "currency": "EUR"}' \
| jq '{id, status, kind, amount, currency, destination_account_id}'
amountis the EUR that leaves, at least1.00, two decimal places. What arrives is the provider's conversion at its rate, less its fees; the quote'sdestination_amount_estimateestimates it, in USDC, never locked.- No
beneficiary_id,fx_quote_idorreference. A key needs no step-up. - In the sandbox the
202already readssettled(kindconvert). The estimated USDC is deposited into your Solana wallet and booked within seconds, withpayin.received; the payout shows it asarrivalwithin about ten minutes. Two converts within ten minutes whose estimates lie within about 5 % of each other cannot be told apart: space them out or size them differently. At most 30 converts an hour. - Plan limits apply, measured on the EUR:
422 plan_limit_exceeded. Too little EUR is400 insufficient_balance.
Every case: Convert EUR into USDC.
4. Send to a trusted wallet
The USDC is in your Solana wallet: quote and send it with
"source_network": "solana" to a trusted beneficiary, and follow it, as in
steps 5 to 8.
curl -s "$CXC_BASE/v1/banking/accounts/$USDC/balance" \
-H "Authorization: Bearer $CXC_KEY" | jq '.balances[] | {available, by_network}'
5. And on to a bank
From the USDC account — and for some kinds an EURC account — a key also pays a bank account, in the sandbox: to a trusted beneficiary of that kind, which a person approves in the web app as in step 5 of wallet to wallet, or back to your own EUR account with no entry.
| Destination | destination.type | From | Region |
|---|---|---|---|
| Your own EUR account (send-back) | destination_account_id, no entry | USDC | Every |
| An IBAN, EUR over SEPA | iban | USDC, EURC | Every |
| A US account, USD over ACH or wire | us_bank | USDC, EURC | Every |
| A UK account, GBP over Faster Payments | gb_bank | USDC | Outside the EEA |
| A Mexican CLABE, MXN over SPEI | clabe | USDC, EURC | Outside the EEA |
| A Brazilian Pix key, BRL over Pix | pix | USDC | Outside the EEA |
- The send-back is offered where the USDC account's
own_account_payoutsreads["EUR"]; itsamountis the EUR that arrives, and the account must be held in your company's legal name. - Each bank kind is paid over a rail the provider has enabled for you —
railsinGET /v1/banking/kyb/digital-assets, which the sandbox approval requests and approves; check that the rail readsenabled. Otherwise409 digital_assets_onboarding_requiredwithdetails.rail. - A Pix payout needs a business outside the EEA, USDC, at least 2 USDC,
and the BRL (Pix) rail. A business in the EEA is refused
422 corridor_unsupported, and so is the approval of apixentry. - In the sandbox these payouts settle as in step 9.
Fields, minimums and examples: To a bank account outside SEPA and Send back to your EUR account.
What does not exist
- No on-ramp straight into an outside wallet. EUR paid to the IBAN always arrives as USDC in your own wallet at the provider, on Solana. Paying it on to another wallet is a second payout.
- No BRL pay-in — no Pix in, nor any other BRL deposit — and no USD, GBP or MXN pay-in into a digital-asset account. The only fiat way in is EUR by SEPA to the USDC account's IBAN, and it arrives as USDC, never as EURC. Pix exists only as a payout from USDC.
- No send from a wallet held elsewhere. Every send leaves one of your wallets at the provider. No route sends from an outside wallet, and no deposit address pays a bank account on its own.
- No single request for the whole way. Each leg is its own payout, with
its own
Idempotency-Keyand status. - No production. None of this is offered in production.