API keys and trusted beneficiaries
Sandbox only. Every digital-asset action works with an API key — onboarding, accounts, wallets, quotes, test funds, swaps, send-back and convert. A send to a third party is the exception: an API key sends only to a trusted beneficiary — a destination a person of your account approved, with their second factor. A key names the entry, never the destination: every detail of where the money goes is read from the entry.
| A person's session | An API key | |
|---|---|---|
| Send on chain, to an IBAN, to a bank account, by @handle | Inline or to a trusted beneficiary, with a step-up | A trusted beneficiary only, no step-up |
| Approve or revoke a trusted beneficiary | Yes, with a step-up; the seat needs payouts.create | Never — 403 session_required, master keys included |
| List trusted beneficiaries | Yes | accounts:read |
| Swap, send back, convert | With a step-up | payments:write, no step-up |
Why. A leaked key is the threat: whoever holds it can do everything its scopes allow. Because a key can only pay destinations a person already approved — after the cooldown, within the caps — the worst a leaked key does is pay those. Revoking the key or the entry stops it.
digital_actions on each digital-asset account says this deployment's rule:
trusted_beneficiaries_required_for_api_keys: true and
trusted_beneficiary_cooldown_seconds.
Approve one in the web app
- Sign in at app.sandbox.cashxchain.com.
- On the Digital Assets page, on the Approved recipients card, choose Manage, then Approve a recipient.
- Give it a name, enter the destination and who holds it, and confirm with
your second factor. An address of one of your own wallets is refused
(
422 destination_is_own_wallet).
Then read its id with your key:
curl -s "$CXC_BASE/v1/banking/digital-assets/trusted-beneficiaries" \
-H "Authorization: Bearer $CXC_KEY" \
| jq '.trusted_beneficiaries[] | {id, kind, label, status, api_keys_usable_from}'
The cooldown
How long after an entry is approved before an API key may send to it: the time a person has to notice an approval they did not make. Sessions are not subject to it.
| Cooldown | |
|---|---|
| Sandbox | 0 — a key may use an entry at once |
| Production | 24 hours (no production sends yet) |
While it runs, the entry's status is cooling_down and an API key's send is
422 beneficiary_not_trusted with details.reason: "cooling_down" and
details.api_keys_usable_from.
GET /v1/banking/digital-assets/trusted-beneficiaries
Your entries, newest first, revoked ones included. A session or a key with
accounts:read.
{
"trusted_beneficiaries": [
{
"id": "6a0f5a8e-0c55-4d3f-9a43-2f7c7b2f0e11",
"kind": "onchain",
"label": "Muster Handels — treasury",
"network": "solana",
"address": "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM",
"beneficiary": {
"is_self": false, "type": "business", "name": "Muster Handels GmbH",
"address": { "street_line_1": "Hauptstr. 1", "city": "Berlin", "postal_code": "10115", "country": "DE" },
"business_identifier": { "type": "lei", "value": "506700GE1G29325QX363", "issuing_country": null },
"wallet_type": "external"
},
"created_by": "user:usr_2b…",
"created_at": "2026-11-03T09:00:00Z",
"revoked_at": null,
"revoked_by": null,
"status": "active",
"api_keys_usable_from": "2026-11-03T09:00:00Z"
}
]
}
status | |
|---|---|
active | A person or an API key may send to it |
cooling_down | A person may; an API key from api_keys_usable_from |
revoked | Nobody may |
Fields of other kinds are absent. An IBAN entry answers iban, bic and
owner; a bank-account entry its coordinates in bank_account (a US entry's
bank_name with them) and owner; a handle entry handle.
POST /v1/banking/digital-assets/trusted-beneficiaries
A person of the account only, in their own session, with a step-up; the
seat needs payouts.create. Idempotency-Key required. The body takes what
the send it stands for takes, checked by the same rules — kind (the
destination.type a send to it uses), a label (1 to 100 printable
characters), and that kind's fields, flat:
kind | Fields |
|---|---|
onchain | network, address, memo (optional, Stellar only), beneficiary — as an on-chain send |
iban | iban, bic (optional), owner — as a send to an IBAN |
us_bank | routing_number, account_number, account_type, bank_name (required), owner with a whole US address — as a US send |
gb_bank | sort_code, account_number, owner |
clabe | clabe, owner |
pix | pix_key, document_number (optional), owner |
cxc_handle | handle and nothing else |
{ "kind": "onchain", "label": "Muster Handels — treasury",
"network": "solana", "address": "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM",
"beneficiary": { "is_self": false, "type": "business", "name": "Muster Handels GmbH",
"address": { "street_line_1": "Hauptstr. 1", "city": "Berlin", "postal_code": "10115", "country": "DE" },
"business_identifier": { "type": "lei", "value": "506700GE1G29325QX363" },
"wallet_type": "external" } }
{ "kind": "iban", "label": "Erika — rent",
"iban": "DE89 3704 0044 0532 0130 00", "bic": "COBADEFFXXX",
"owner": { "type": "individual", "first_name": "Erika", "last_name": "Mustermann",
"address": { "street_line_1": "Domkloster 4", "city": "Köln", "postal_code": "50667", "country": "DE" } } }
{ "kind": "us_bank", "label": "Acme — ACH",
"routing_number": "101019644", "account_number": "123456789", "account_type": "checking",
"bank_name": "Lead Bank",
"owner": { "type": "business", "business_name": "Acme Supplies Inc",
"business_identifier": { "type": "lei", "value": "506700GE1G29325QX363" },
"address": { "street_line_1": "923 Folsom Street", "city": "San Francisco",
"postal_code": "94107", "state": "CA", "country": "US" } } }
{ "kind": "gb_bank", "label": "Maria — London",
"sort_code": "123456", "account_number": "34567890",
"owner": { "type": "individual", "first_name": "Maria", "last_name": "Silva",
"address": { "street_line_1": "10 Downing Street", "city": "London", "country": "GB" } } }
{ "kind": "clabe", "label": "Maria — SPEI",
"clabe": "032180000118359719",
"owner": { "type": "individual", "first_name": "Maria", "last_name": "Silva",
"address": { "street_line_1": "Av. Reforma 222", "city": "Ciudad de Mexico", "country": "MX" } } }
{ "kind": "pix", "label": "Maria — Pix",
"pix_key": "recipient@example.com", "document_number": "52998224725",
"owner": { "type": "individual", "first_name": "Maria", "last_name": "Silva",
"address": { "street_line_1": "Av. Paulista 1000", "city": "Sao Paulo", "country": "BR" } } }
{ "kind": "cxc_handle", "label": "Partner GmbH", "handle": "@their_handle" }
- A field of another kind is
400; so is abank_nameon any kind butus_bank. - One of your own wallets' addresses is
422 destination_is_own_wallet. - A destination has at most one active entry: approving it again is
409 trusted_beneficiary_existswithdetails.trusted_beneficiary_id(revoke it first). A US account under another bank name is the same entry. - GBP, MXN and BRL for a business in the EEA are refused at approval:
422 corridor_unsupported,details.corridor{"type": "bank", "destination_type", "destination_currency"}. Nothing is approved and the step-up is not spent. - A
cxc_handleentry approves the customer the handle names now. If the handle later names someone else, a send to the entry is refused (422 beneficiary_not_trusted,reason: "recipient_changed"). - The same
Idempotency-Keyand body answer200with the entry (no step-up needed); another body under the key is409 idempotency_key_reuse.
The step-up's binding: amount 0, currency *, and recipient
trusted: followed by the destination in the form its send binds —
trusted:solana:{address} (trusted:stellar:{address}:{memo} with a memo),
trusted:{IBAN} (no spaces, upper case), trusted:us:{routing}:{account}:{checking|savings},
trusted:gb:{sort code}:{account}, trusted:clabe:{clabe},
trusted:pix:{key}[|{document}], trusted:@{handle}. See Step-up.
Answer: 201 with the entry, as in the list. The step-up is spent only
after every other check has passed.
| Status | error | When |
|---|---|---|
400 | bad_request | A malformed field (an address not on the chain, an IBAN, BIC, country, business identifier, a blank memo or one over 64 characters), a field of another kind, a missing network, address or iban, a us_bank without bank_name, a label or holder's name out of bounds; no Idempotency-Key |
401 | two_factor_required | No step-up |
403 | session_required | Any API key, master keys included |
403 | delegation_not_supported | A session acting for another account (Cxc-Account) |
403 | permission_denied, step_up_binding_mismatch, step_up_not_linked, step_up_invalid, step_up_expired, step_up_already_used | A seat without payouts.create; the step-up |
403 | account_not_verified | Your business is not verified |
409 | trusted_beneficiary_exists, idempotency_key_reuse | Above |
422 | travel_rule_incomplete (details.fields: beneficiary.…, owner.…), destination_is_own_wallet, network_unsupported, corridor_unsupported | Above |
503 | digital_assets_unavailable | No digital-asset rail on this deployment |
A us_bank entry approved before bank_name was required answers no
bank_name and cannot be paid: its send, and a quote of it, is
400 bad_request with details.fields: ["bank_name"]. Revoke it and approve
it again with one.
DELETE /v1/banking/digital-assets/trusted-beneficiaries/{id}
The same callers, with a step-up bound to amount 0, currency *,
recipient untrusted:{id}. Answer: 200 with the entry,
status: "revoked", revoked_at and revoked_by. Revoking is final — the
entry stays and keeps naming the payouts made to it, and nobody sends to it
again; approve the destination anew for a new entry. Revoking a revoked entry
answers it as it stands, with no step-up. 404 for an id that is not yours.
A send whose hold on the wallet was written before the revocation committed can still complete. To stop a key's sends at once, revoke the key as well.
Sending with an API key
POST /v1/banking/payouts (and its quote) with
destination: {"type": "<the entry's kind>", "trusted_beneficiary_id": "…"}
and nothing else in destination. amount, currency,
source_network, reference (to an IBAN or bank account) — and for a US
account rail — are yours, as in any send. No step-up.
# On chain — the entry's chain is where the money goes; source_network is the
# wallet it leaves (the same chain, or another for a cross-chain send).
curl -s -X POST "$CXC_BASE/v1/banking/payouts" \
-H "Authorization: Bearer $CXC_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: key-onchain-$RUN" \
-d '{"source_account_id": "'"$USDC"'", "source_network": "solana",
"amount": "5.00", "currency": "USDC",
"destination": {"type": "onchain", "trusted_beneficiary_id": "'"$TRUSTED_ONCHAIN"'"}}'
# To an IBAN
curl -s -X POST "$CXC_BASE/v1/banking/payouts" \
-H "Authorization: Bearer $CXC_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: key-iban-$RUN" \
-d '{"source_account_id": "'"$EURC"'", "source_network": "solana",
"amount": "10.00", "currency": "EURC", "reference": "Invoice 4711",
"destination": {"type": "iban", "trusted_beneficiary_id": "'"$TRUSTED_IBAN"'"}}'
# To a US account, by wire
curl -s -X POST "$CXC_BASE/v1/banking/payouts" \
-H "Authorization: Bearer $CXC_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: key-us-$RUN" \
-d '{"source_account_id": "'"$USDC"'", "source_network": "solana",
"amount": "5.00", "currency": "USDC",
"destination": {"type": "us_bank", "trusted_beneficiary_id": "'"$TRUSTED_US"'", "rail": "wire"}}'
# By @handle
curl -s -X POST "$CXC_BASE/v1/banking/payouts" \
-H "Authorization: Bearer $CXC_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: key-handle-$RUN" \
-d '{"source_account_id": "'"$USDC"'", "source_network": "solana",
"amount": "5.00", "currency": "USDC",
"destination": {"type": "cxc_handle", "trusted_beneficiary_id": "'"$TRUSTED_HANDLE"'"}}'
gb_bank, clabe and pix entries are sent the same way, with their type.
- The entry's destination and travel-rule data are checked again exactly as an
inline destination is, and the send is checked against the entry once more
when its hold is written: an entry revoked by then is refused
(
422 destination_not_payable,reason: "not_trusted", recordedfailed, nothing sent). - Everything else is a send's: the caps, holds, minimums, replays,
earlier_payout_unconfirmed,payout_outcome_unknown. A replay answers even after the entry is revoked. - A session may send the same body; its step-up is bound to where the money
goes — the entry's own destination (
solana:{address}, its IBAN, …) — not to the entry's id.
| Status | error | When |
|---|---|---|
400 | trusted_beneficiary_required | From an API key: an address, network, memo, beneficiary, iban, bic, owner, a bank account's fields or bank_name in destination, or no trusted_beneficiary_id. Nothing read, nothing recorded |
400 | bad_request | The entry is of another kind (type must be its kind); a trusted_beneficiary_id beside inline details |
403 | insufficient_scope | A restricted key without payments:write (details.required_scope) |
403 | session_required | A key or session acting for another account (Cxc-Account) |
404 | not_found | No entry of yours has this id |
422 | beneficiary_not_trusted | details.reason: revoked (details.revoked_at), cooling_down (details.api_keys_usable_from), or recipient_changed (a handle entry whose handle names someone else now); details.trusted_beneficiary_id |
Scopes, for reference
A restricted key (cxc_rk_) needs the scope named; a secret key (cxc_sk_)
and a master key hold every scope. Publishable keys reach none of these.
| Action | Route | API key |
|---|---|---|
| Onboarding status, start | GET / POST /v1/banking/kyb/digital-assets | accounts:read / accounts:write |
| Sandbox approval | POST /v1/sandbox/banking/kyb/digital-assets/approve | accounts:write |
| Open an account, a wallet | POST /v1/banking/accounts, POST /v1/banking/digital-assets/wallets | accounts:write |
| Accounts, balances, wallets, capabilities, trusted beneficiaries | GET | accounts:read |
| Credits, payouts, quotes | GET /v1/transactions, GET /v1/banking/payouts, POST /v1/banking/payouts/quote | payments:read |
| Every send, swap, send-back, convert | POST /v1/banking/payouts | payments:write |
| Test funds, fiat test funds, a send-back's arrival, a send's completion | POST /v1/sandbox/banking/accounts/{id}/simulate-deposit, POST /v1/sandbox/banking/faucet, POST /v1/sandbox/banking/payouts/{id}/simulate-arrival, POST /v1/sandbox/banking/payouts/{id}/simulate-completion | payments:write |
| Approve, revoke a trusted beneficiary | POST, DELETE …/trusted-beneficiaries | never |
Two hops through your own bank account. A key with payments:write may
send a digital balance back to your own EUR account (no step-up), and — with
beneficiaries:write — register a bank beneficiary and pay it. Together a
leaked key with both scopes reaches a destination of its own choosing in two
hops. Give an integration's restricted key the narrowest scopes it needs: no
beneficiaries:write beside payments:write unless it must register payees.