Quickstart: your first payout
Every money operation lives under /v1/banking/*. This page walks the whole
path, from a fresh sandbox key to a settled payout, as the calls you will
actually make, in the order you will make them. Each snippet below was run
against the sandbox and the responses shown are what came back.
The same paths, request bodies, response shapes and error codes work in production. What changes between the two is the rail behind the API and, because of it, timing: in the sandbox a payout settles inside the request; in production it takes minutes to days. Where that matters, the step says so.
You need curl and jq.
Step 0 — Get a sandbox key
- Sign up for the sandbox at auth.sandbox.cashxchain.com/register.
- In the sandbox developer console,
dev.sandbox.cashxchain.com, open
API keys → Create key and create a Secret key. It starts with
cxc_sk_sandbox_and is shown once.
export CXC_BASE=https://api.sandbox.cashxchain.com
export CXC_KEY=cxc_sk_sandbox_... # your secret key
Those two lines are the only thing that changes when you go live:
https://api.cashxchain.com and a cxc_sk_live_ key. See
Environments.
Step 1 — Authenticate
Every request carries the key as a bearer token. Listing your accounts proves the key works and shows you what you already hold:
curl -s "$CXC_BASE/v1/banking/accounts" \
-H "Authorization: Bearer $CXC_KEY" | jq '.accounts[] | {id, currency, status}'
{
"id": "mockacct_7d2f19c4e8a04b6f9e1c3a5b7d9f0e21_eur",
"currency": "EUR",
"status": "active"
}
A new sandbox account already holds one currency account, chosen from the country you signed up with: EUR for the EU, EEA and Switzerland, GBP for the UK, CAD for Canada, USD for everywhere else.
A 401 here means the key is missing, mistyped, revoked, or from the other
environment — the API answers all of those with the same
{"error": "unauthorized"} so keys cannot be probed. A sandbox key is unknown
to production and the other way round. See Authentication.
Step 2 — Know where verification (KYB) sits
Before a business can move money in production, it is verified: you submit a
KYB application, our banking partner reviews it, and only when the account is
active do payouts and conversions go through. Until then every money route
answers 403 account_not_verified before anything is sent to the bank —
details.next_action says whether to finish the application, wait for the
review, or contact support — so in production every call on this page fails
before approval.
The sandbox does not make you wait. It lets an unverified (pending)
account move test money, so you can do everything on this page before — or
without — submitting KYB. When you are ready to build the onboarding flow, the
steps and what the sandbox does differently are in Verification (KYB).
Step 3 — Open an account
This guide pays in EUR, so it needs an EUR account. Open one — if you already hold it, the API says so and you carry on:
curl -s -X POST "$CXC_BASE/v1/banking/accounts" \
-H "Authorization: Bearer $CXC_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: quickstart-open-eur" \
-d '{"currency": "EUR"}' | jq '{currency_account, error, message}'
201 returns the new currency_account and, in the sandbox, the bank account
with its IBAN. If you already hold EUR you get 409:
{
"currency_account": null,
"error": "conflict",
"message": "you already hold a EUR account"
}
Either way, take the account id — it is the source of the payout:
SOURCE=$(curl -s "$CXC_BASE/v1/banking/accounts" \
-H "Authorization: Bearer $CXC_KEY" \
| jq -r '.accounts[] | select(.currency == "EUR") | .id')
echo "$SOURCE"
In production, POST /v1/banking/accounts can answer 201 with a
currency_account whose status is requested and no bank account yet: the
partner opens it, and it appears in GET /v1/banking/accounts once it exists.
See Accounts.
Step 4 — Add test funds
The sandbox faucet credits test money to an account you hold. It takes your
secret key (or a restricted key with payments:write):
curl -s -X POST "$CXC_BASE/v1/sandbox/banking/faucet" \
-H "Authorization: Bearer $CXC_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: quickstart-faucet-eur" \
-d '{"currency": "EUR", "amount": 100}' | jq
amount is whole units, 1 to 100,000. Under an Idempotency-Key the credit is
made once: running this again credits nothing. Without one, every call credits
again. Or top up in the sandbox web app: sign in at
app.sandbox.cashxchain.com, then
Accounts → Get test funds on the EUR account (10, 100, 1,000 or 10,000).
Then confirm the balance with your key:
curl -s "$CXC_BASE/v1/banking/accounts/$SOURCE/balance" \
-H "Authorization: Bearer $CXC_KEY" | jq '.balances[0] | {currency, available}'
{
"currency": "EUR",
"available": "100.0000"
}
That is a fresh account; earlier credits add to it.
The faucet does not exist in production. Details in Environments.
Step 5 — Register a beneficiary
A payout goes to a beneficiary you registered first. What a destination needs depends on its corridor — an INR payout needs an IFSC code, a CAD one an institution and transit number — so ask before you build the form:
curl -s "$CXC_BASE/v1/banking/corridors?currency=EUR&country=DE&entity_type=business" \
-H "Authorization: Bearer $CXC_KEY" \
| jq '{accepted_schemes, rails, fields: [.fields[] | {key, required}]}'
{
"accepted_schemes": ["iban"],
"rails": ["sepa", "sepa_instant"],
"fields": [{ "key": "bic", "required": false }]
}
An EUR account in Germany takes an IBAN; the BIC is optional. Register it:
BENEFICIARY=$(curl -s -X POST "$CXC_BASE/v1/banking/beneficiaries" \
-H "Authorization: Bearer $CXC_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: quickstart-beneficiary-kettlewick" \
-d '{
"beneficiary_type": "business",
"name": "Kettlewick Trading GmbH",
"currency": "EUR",
"country_code": "DE",
"account": {"scheme": "iban", "iban": "DE89370400440532013000"}
}' | jq -r '.id')
echo "$BENEFICIARY"
The payee-name check
Since October 2026, UK (Confirmation of Payee) and EU (Verification of Payee) payments are checked against the name the receiving bank holds for the account. Ask before you pay:
curl -s -X POST "$CXC_BASE/v1/banking/beneficiaries/$BENEFICIARY/verify" \
-H "Authorization: Bearer $CXC_KEY" | jq
{
"outcome": "not_checked",
"may_pay": true
}
The check is a single synchronous call — there is no pending state to poll.
The sandbox rail has no name check and always answers not_checked. In
production most GBP and EUR destinations are checked against the receiving
bank, and can also answer matched, close_match, no_match or invalid.
A close match or a mismatch stops the payout until the payer has seen the
result; invalid stops it outright. What to do with each outcome is in
Beneficiaries.
Step 6 — Price the payout
A quote moves nothing. It tells you what the payout will cost from this account:
curl -s -X POST "$CXC_BASE/v1/banking/payouts/quote" \
-H "Authorization: Bearer $CXC_KEY" \
-H "Content-Type: application/json" \
-d "{
\"source_account_id\": \"$SOURCE\",
\"beneficiary_id\": \"$BENEFICIARY\",
\"currency\": \"EUR\",
\"amount\": \"10.00\"
}" | jq
{
"source_currency": "EUR",
"source_amount": "15.00",
"fee": "5.00",
"payment_fee": "5.00",
"rate": "1",
"currency": "EUR",
"amount": "10.00",
"simulated": true
}
amount is what the beneficiary receives; source_amount is what leaves your
account, fee included. simulated: true marks a sandbox price.
Step 7 — Send it
PAYOUT=$(curl -s -X POST "$CXC_BASE/v1/banking/payouts" \
-H "Authorization: Bearer $CXC_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: quickstart-payout-inv-0042" \
-d "{
\"source_account_id\": \"$SOURCE\",
\"beneficiary_id\": \"$BENEFICIARY\",
\"amount\": \"10.00\",
\"currency\": \"EUR\",
\"reference\": \"INV-0042\"
}")
echo "$PAYOUT" | jq
PAYOUT_ID=$(echo "$PAYOUT" | jq -r '.id')
{
"id": "5f0c2e9a-3b1d-4c7e-8a6f-2d9b4e1c7a30",
"status": "settled",
"partner_payment_id": "sandbox_5f0c2e9a3b1d4c7e8a6f2d9b4e1c7a30",
"amount": "10.0000",
"currency": "EUR",
"direction": "outbound",
"partner_status": "simulated",
"created_at": "2026-10-15T20:14:49.165075Z",
"updated_at": "2026-10-15T20:14:49.187835Z",
"submitted_at": "2026-10-15T20:14:49.182925Z",
"settled_at": "2026-10-15T20:14:49.187835Z"
}
The response is 202 Accepted. Keep id — it is the payout's permanent
handle, and the one to quote to support.
The Idempotency-Key makes the call safe to retry. If the connection drops,
send the same request with the same key: you get 200 and the payout that
was already created, never a second one. The same key with a different body is
refused with 409 idempotency_key_reuse. Derive the key from your own record —
an invoice or payment id — so a retry cannot accidentally mint a new one. See
Idempotency.
Step 8 — Track it to the end
curl -s "$CXC_BASE/v1/banking/payouts/$PAYOUT_ID" \
-H "Authorization: Bearer $CXC_KEY" | jq '{id, status, submitted_at, settled_at}'
In the sandbox the payout is already settled — the simulated rail books it
inside the request. In production it is not. The first response says
submitted, and the status then moves to processing and settled (or
failed) as the banking partner reports back, which takes from minutes to
days depending on the rail. settled_at is the field that separates sent
from arrived.
Today you follow that by polling this route. There is no payout webhook yet — the webhook catalogue has two events and neither is about payouts. The status values and every legal transition are in Payouts.
The whole flow as a script
The same eight steps with error handling, ready to adapt. Both were run against
the sandbox. Set CXC_BASE and CXC_KEY first.
- TypeScript (Node 18+)
- Python 3.9+
// node first-payout.mjs — no dependencies
const BASE = process.env.CXC_BASE ?? 'https://api.sandbox.cashxchain.com';
const KEY = process.env.CXC_KEY;
async function cxc(method, path, { body, idempotencyKey } = {}) {
const res = await fetch(`${BASE}${path}`, {
method,
headers: {
Authorization: `Bearer ${KEY}`,
...(body && { 'Content-Type': 'application/json' }),
...(idempotencyKey && { 'Idempotency-Key': idempotencyKey }),
},
body: body && JSON.stringify(body),
});
const requestId = res.headers.get('x-request-id');
const text = await res.text();
const data = text ? JSON.parse(text) : null;
if (!res.ok) {
// `error` is the stable code to branch on; quote requestId to support.
const err = new Error(`${res.status} ${data?.error}: ${data?.message} (request ${requestId})`);
Object.assign(err, { status: res.status, code: data?.error, details: data?.details });
throw err;
}
return data;
}
// One key per business operation, reused on every retry of that operation.
const run = process.env.RUN_ID ?? 'INV-0042';
const { accounts } = await cxc('GET', '/v1/banking/accounts');
const source = accounts.find((a) => a.currency === 'EUR' && a.status === 'active');
if (!source) throw new Error('Open an EUR account first: POST /v1/banking/accounts {"currency":"EUR"}');
const beneficiary = await cxc('POST', '/v1/banking/beneficiaries', {
idempotencyKey: `beneficiary-kettlewick-${run}`,
body: {
beneficiary_type: 'business',
name: 'Kettlewick Trading GmbH',
currency: 'EUR',
country_code: 'DE',
account: { scheme: 'iban', iban: 'DE89370400440532013000' },
},
});
const check = await cxc('POST', `/v1/banking/beneficiaries/${beneficiary.id}/verify`);
if (!check.may_pay) throw new Error(`Payee check: ${check.outcome} — see the beneficiaries guide`);
const quote = await cxc('POST', '/v1/banking/payouts/quote', {
body: { source_account_id: source.id, beneficiary_id: beneficiary.id, currency: 'EUR', amount: '10.00' },
});
console.log(`Paying 10.00 EUR costs ${quote.source_amount} ${quote.source_currency} (fee ${quote.fee})`);
let payout = await cxc('POST', '/v1/banking/payouts', {
idempotencyKey: `payout-${run}`,
body: {
source_account_id: source.id,
beneficiary_id: beneficiary.id,
amount: '10.00',
currency: 'EUR',
reference: run,
},
});
// Sandbox settles inside the request; production moves through
// submitted → processing → settled over minutes to days.
const FINAL = new Set(['settled', 'failed', 'cancelled', 'returned']);
while (!FINAL.has(payout.status)) {
await new Promise((r) => setTimeout(r, 30_000));
payout = await cxc('GET', `/v1/banking/payouts/${payout.id}`);
}
console.log(`Payout ${payout.id}: ${payout.status} at ${payout.settled_at ?? payout.updated_at}`);
# python first_payout.py — pip install requests
import os
import time
import requests
BASE = os.environ.get("CXC_BASE", "https://api.sandbox.cashxchain.com")
KEY = os.environ["CXC_KEY"]
class CxcError(Exception):
def __init__(self, status, body, request_id):
self.status, self.code = status, body.get("error")
self.details = body.get("details")
super().__init__(f"{status} {self.code}: {body.get('message')} (request {request_id})")
def cxc(method, path, body=None, idempotency_key=None):
headers = {"Authorization": f"Bearer {KEY}"}
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
res = requests.request(method, BASE + path, json=body, headers=headers, timeout=30)
data = res.json() if res.content else None
if not res.ok:
# `error` is the stable code to branch on; quote the request id to support.
raise CxcError(res.status_code, data or {}, res.headers.get("x-request-id"))
return data
# One key per business operation, reused on every retry of that operation.
run = os.environ.get("RUN_ID", "INV-0042")
accounts = cxc("GET", "/v1/banking/accounts")["accounts"]
source = next(a for a in accounts if a["currency"] == "EUR" and a["status"] == "active")
beneficiary = cxc(
"POST",
"/v1/banking/beneficiaries",
idempotency_key=f"beneficiary-kettlewick-{run}",
body={
"beneficiary_type": "business",
"name": "Kettlewick Trading GmbH",
"currency": "EUR",
"country_code": "DE",
"account": {"scheme": "iban", "iban": "DE89370400440532013000"},
},
)
check = cxc("POST", f"/v1/banking/beneficiaries/{beneficiary['id']}/verify")
if not check["may_pay"]:
raise SystemExit(f"Payee check: {check['outcome']} — see the beneficiaries guide")
quote = cxc(
"POST",
"/v1/banking/payouts/quote",
body={"source_account_id": source["id"], "beneficiary_id": beneficiary["id"],
"currency": "EUR", "amount": "10.00"},
)
print(f"Paying 10.00 EUR costs {quote['source_amount']} {quote['source_currency']} (fee {quote['fee']})")
payout = cxc(
"POST",
"/v1/banking/payouts",
idempotency_key=f"payout-{run}",
body={"source_account_id": source["id"], "beneficiary_id": beneficiary["id"],
"amount": "10.00", "currency": "EUR", "reference": run},
)
# Sandbox settles inside the request; production moves through
# submitted -> processing -> settled over minutes to days.
while payout["status"] not in ("settled", "failed", "cancelled", "returned"):
time.sleep(30)
payout = cxc("GET", f"/v1/banking/payouts/{payout['id']}")
print(f"Payout {payout['id']}: {payout['status']} at {payout.get('settled_at') or payout['updated_at']}")
Paying 10.00 EUR costs 15.00 EUR (fee 5.00)
Payout 320b2475-97b8-4485-9baa-7f61c13bd6ca: settled at 2026-10-15T20:15:14.269773Z
Before you go live
Changing the base URL and the key is the whole code change. These are the behaviours the sandbox does not show you:
| Sandbox | Production | |
|---|---|---|
| Verification | Payouts allowed while pending | Nothing on the money API works until the partner approves; review takes days |
| Opening an account | Instant, with bank details | May return requested; details arrive later |
| Payee-name check | Always not_checked | Real outcomes for GBP and EUR destinations |
| Payout status | settled in the 202 | submitted → processing → settled, minutes to days |
| Test money | Web app or faucet | None — fund by bank transfer to your account details |
The full comparison is in Environments.
Next
- Authentication — key types, what each can reach, scopes
- Errors — every code, and what to do about it
- Idempotency — retries that cannot double-pay
- Payouts — the full contract and lifecycle
- Digital assets — a USDC account and a first on-chain send, in the sandbox
- Route reference — every endpoint