Skip to main content

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​

  1. Sign up for the sandbox at auth.sandbox.cashxchain.com/register.
  2. 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.
Bash
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:

Bash
curl -s "$CXC_BASE/v1/banking/accounts" \
-H "Authorization: Bearer $CXC_KEY" | jq '.accounts[] | {id, currency, status}'
JSON
{
"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:

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

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

Bash
SOURCE=$(curl -s "$CXC_BASE/v1/banking/accounts" \
-H "Authorization: Bearer $CXC_KEY" \
| jq -r '.accounts[] | select(.currency == "EUR") | .id')
echo "$SOURCE"
Production opens accounts asynchronously

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

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

Bash
curl -s "$CXC_BASE/v1/banking/accounts/$SOURCE/balance" \
-H "Authorization: Bearer $CXC_KEY" | jq '.balances[0] | {currency, available}'
JSON
{
"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:

Bash
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}]}'
JSON
{
"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:

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

Bash
curl -s -X POST "$CXC_BASE/v1/banking/beneficiaries/$BENEFICIARY/verify" \
-H "Authorization: Bearer $CXC_KEY" | jq
JSON
{
"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:

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\": \"$SOURCE\",
\"beneficiary_id\": \"$BENEFICIARY\",
\"currency\": \"EUR\",
\"amount\": \"10.00\"
}" | jq
JSON
{
"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​

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

Bash
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 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}`);
Plain text
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:

SandboxProduction
VerificationPayouts allowed while pendingNothing on the money API works until the partner approves; review takes days
Opening an accountInstant, with bank detailsMay return requested; details arrive later
Payee-name checkAlways not_checkedReal outcomes for GBP and EUR destinations
Payout statussettled in the 202submitted → processing → settled, minutes to days
Test moneyWeb app or faucetNone — fund by bank transfer to your account details

The full comparison is in Environments.

Next​