Authentication
Every request carries one credential as a bearer token in the Authorization
header:
curl https://api.sandbox.cashxchain.com/v1/banking/accounts \
-H "Authorization: Bearer $CXC_KEY"
The header is the only place a key is read. A token in the query string
(?access_token=…) is ignored — URLs leak through logs, browser history and
Referer headers. (The one route that reads a credential from elsewhere is the
dashboard event stream, which a browser opens
with a cookie.)
Credential types
| Credential | Prefix | Issued by | Use it for |
|---|---|---|---|
| Secret key | cxc_sk_ | You, in the console or via the API | Server-side integrations. The key that moves money. |
| Restricted key | cxc_rk_ | You | A service that needs a narrow, scoped slice of the API — the money API included, with the right scopes. A key never does more than the seat that minted it. |
| Publishable key | cxc_pk_ | You | Read-only calls from code you do not control, such as a browser. Today that is indicative FX rates and nothing else. |
| Master key | cxc_mk_ | CashXChain, on request | Platforms that act for businesses they onboarded. See Platforms. |
| Dashboard session | a JWT | The sign-in flow | The console and web app. Not for integrations. |
Keys you create are self-service: API keys → Create key in the
console, or POST /v1/api-keys — see
Managing API keys. Master keys are never self-service.
What each credential can reach
The API is split into surfaces, and the surface decides who may call it before any scope is looked at. A scope can narrow what a key may do on a surface it already reaches; it can never open a surface the key type is refused.
| Surface | Routes | Session | mk_ | sk_ | rk_ | pk_ |
|---|---|---|---|---|---|---|
| Money | /v1/banking/* — the digital-asset routes included — and the sandbox's money tools (/v1/sandbox/banking/kyb/digital-assets/approve, …/accounts/{id}/simulate-deposit, …/payouts/{id}/simulate-arrival, …/payouts/{id}/simulate-completion, /v1/sandbox/banking/faucet) | ✓ | ✓ | ✓ | ✓ scoped | — |
| Developer | /v1/fx/rates, /v1/webhooks*, /v1/request-logs*, /v1/usage/*, /v1/api-keys*, /v1/meta/scopes, /v1/platform/accounts, /v1/transactions*, /v1/contacts*, /v1/accounts/me/balance, /v1/utility/*, Collect's merchant routes | ✓ | ✓ | ✓ | ✓ scoped | GET /v1/fx/rates only |
| Dashboard | /v1/accounts* (except /v1/accounts/me/balance), /v1/me, /v1/notifications/ticket, billing, Collect onboarding and group payments, usernames, pay page | ✓ | ✓ | — | — | — |
| Sandbox tools | The other /v1/sandbox/* routes, such as adopt-corporate and /v1/sandbox/admin/whoami | ✓ | — | — | — | — |
| Public | /health, /v1/platform-status, /v1/public/* | no credential |
A credential a surface refuses at the door — a pk_ key on the money API, any
regular key on a dashboard route — gets 401 key_not_admitted; the message
names what the route does admit. A key the surface lets in but the route does
not, or that lacks the route's scope, gets 403 with one of the codes
below.
Within a surface, one table decides what each route admits — a scope, "secret
keys only", or "sessions and master keys only" — and GET /v1/meta/scopes
publishes it per scope. The rows that are not simply a scope:
| Route | Who may call it | Others get |
|---|---|---|
/v1/api-keys (list, create, revoke) | A session whose seat holds api_keys.manage (owner, admin, the holder of a personal account), mk_ | 403 permission_denied for a member or viewer session; 403 key_not_admitted for any API key — a leaked key must not mint its successor |
/v1/request-logs*, /v1/usage/* | Session, mk_, sk_ | 403 key_not_admitted for rk_ and pk_ — logs contain request bodies |
/v1/platform/accounts | Session, mk_, sk_, rk_ with accounts:read | 403 key_not_admitted for pk_ |
POST /v1/customers | CashXChain staff | 403 key_not_admitted for every API key |
| Collect onboarding and group payments, billing, usernames, the pay page, grants | Session, mk_ | 403 key_not_admitted |
/internal/* is CashXChain's own operations surface. It takes staff sessions
and service signatures, never an API key of any type — master keys included.
The one public exception is GET /internal/version, which returns build
information and needs no credential.
A restricted key reaches /v1/banking/* with the scopes its routes need. A
key holding accounts:read and payments:read can read accounts, balances,
payees and payouts but cannot send one; payments:write sends payouts and
executes conversions; beneficiaries:write registers and verifies payees.
Publishable keys stay out of the money API entirely. Keep keys on your
servers, one per service, so that revoking one does not stop the others.
Scopes
A scope is <resource>:<action>. Exactly nine exist; the list is served, with
a description and the routes behind each, at GET /v1/meta/scopes (any
credential):
{
"object": "list",
"data": [
{
"scope": "accounts:write",
"group": "Accounts",
"description": "…",
"publishable": false,
"includes": ["accounts:read"],
"routes": [{"method": "POST", "path": "/v1/banking/accounts"}, …]
},
…
]
}
Every route an API key can call has exactly one entry in that table — a
scope, "secret keys only", or "sessions and master keys only" — and the
OpenAPI document takes each operation's security requirement from the same
table. A scope is checked on restricted and publishable keys. A secret key
holds every scope, so an sk_ key created with one scope selected has full
access all the same; a master key bypasses key checks altogether. A dashboard
session is never checked for scopes: what a person may do is decided by their
seat's permissions instead.
A write includes its read. accounts:write opens every accounts:read
route, payments:write every payments:read route, and collect:write every
collect:read route; includes in the catalogue says so.
| Scope | Opens |
|---|---|
accounts:read | GET /v1/banking/accounts, …/{account_id}/balance, …/{account_id}/payin-instructions, …/plan-impact, POST …/{account_id}/statements, GET /v1/banking/corridors, GET /v1/banking/beneficiaries, GET /v1/banking/kyb/precheck-records, GET /v1/banking/kyb/referrals/{external_id}, GET /v1/banking/settlement/*, GET /v1/contacts, GET /v1/webhooks, /v1/webhooks/events, /v1/webhooks/deliveries, GET /v1/platform/accounts, GET /v1/banking/capabilities, GET /v1/banking/kyb/digital-assets, GET /v1/banking/digital-assets/wallets, GET /v1/banking/digital-assets/trusted-beneficiaries |
accounts:write | POST /v1/banking/accounts, PUT /v1/banking/accounts/primary, every POST /v1/banking/kyb/* route (POST /v1/banking/kyb/digital-assets included), POST /v1/banking/digital-assets/wallets, POST /v1/sandbox/banking/kyb/digital-assets/approve, contact writes, POST /v1/webhooks, DELETE /v1/webhooks/{id}, POST /v1/webhooks/{id}/rotate-secret |
beneficiaries:write | POST /v1/banking/beneficiaries, …/{beneficiary_id}/verify, …/{beneficiary_id}/accept-verification |
payments:read | GET /v1/banking/payouts, GET /v1/banking/payouts/{payout_id}, POST /v1/banking/payouts/quote, GET /v1/banking/payout-options, GET /v1/transactions*, GET /v1/accounts/me/balance |
payments:write | POST /v1/banking/payouts (every digital-asset send included — to a third party only to a trusted beneficiary), POST /v1/banking/fx/execute, POST /v1/banking/accounts/close, PUT /v1/banking/settlement/policies, POST /v1/sandbox/banking/accounts/{id}/simulate-deposit, POST /v1/sandbox/banking/payouts/{id}/simulate-arrival, POST /v1/sandbox/banking/payouts/{id}/simulate-completion, POST /v1/sandbox/banking/faucet |
fx:read | GET /v1/fx/rates, POST /v1/banking/fx/quote |
collect:read | GET /v1/collect/status, GET /v1/collect/payment-links, …/{slug}/payments, GET /v1/collect/payments, GET /v1/collect/refunds |
collect:write | POST /v1/collect/payment-links, PATCH …/{slug}, POST /v1/collect/payments |
refunds:write | POST /v1/collect/payments/{payment_intent_id}/refunds |
There is no transfers:*, quotes:*, transactions:*, contacts:*,
claims:* or api_keys:* scope. Creating a key with a scope that does not
exist is a 400 bad_request that lists the valid ones. Keys created before
October 2026 with a scope outside this vocabulary have had it removed; a key
left with no scope is inert, not revoked.
A key never exceeds the seat that minted it. A secret key can only be
created by a seat holding payouts.create and fx.execute; a restricted key
needs, for each scope it carries, that scope's seat permission —
payments:write needs payouts.create and fx.execute, beneficiaries:write
needs payouts.create, collect:write and refunds:write need
collect.links.create. Otherwise 403 permission_denied. See
Managing API keys.
Publishable keys hold only publishable scopes — today that is fx:read
alone — and every other scope is silently dropped at creation. Every
non-GET/HEAD/OPTIONS request from a publishable key is refused with
401 before scopes are considered, and a route whose scope is not publishable
answers 403 key_not_admitted.
Scopes on a key are fixed at creation. To change them, create a new key.
Seat permissions
A dashboard session carries what its seat may do, per organisation role. The backend checks the claim on the actions that matter and nothing else:
| Permission | Gates | Owner, admin | Member | Viewer, api_consumer |
|---|---|---|---|---|
payouts.create | POST /v1/banking/payouts | ✓ | ✓ | — |
fx.execute | POST /v1/banking/fx/execute | ✓ | ✓ | — |
collect.manage | Collect onboarding: POST /v1/collect/accounts, …/account-session | ✓ | — | — |
collect.links.create | The other Collect writes: payment links, refunds, group imports, occasions | ✓ | ✓ | — |
api_keys.manage | /v1/api-keys | ✓ | — | — |
The holder of a personal (non-organisation) account has payouts.create,
fx.execute and api_keys.manage. A seat without the permission gets
403 permission_denied: "your role does not allow this: it needs the
payouts.create permission". API keys carry no seat and are not checked —
which is exactly why a key is capped at its creator's seat when it is minted.
Key format
cxc_sk_live_7QhT2mF9xKpR4vN8wL3dB6sY1aZ5cE0gJu
└┬┘ └┬┘ └─┬┘ └──────────────┬─────────────┘└─┬┘
│ │ │ 32 base62 (~190 bits) └── CRC32, 6 base62
│ │ └── live | sandbox
│ └── sk | pk | rk | mk
└── vendor
Each part has a job:
cxc_is a prefix that belongs to us, so a secret scanner — GitHub push protection, your CI's secret scan — can be given a pattern that matches our keys and nobody else's.- The environment (
liveorsandbox) makes a key that moves real money impossible to confuse with a test key — for you, for your logs and for scanners. - The checksum is a CRC32 over everything before it, base62-encoded. Anyone can tell a real key from a mangled or truncated one without calling us, and editing the type or environment by hand breaks it.
- base62, not hex or base64: shorter for the same entropy, and safe in environment variables, URLs and shell quoting.
Every key also has a public id, cxck_ plus 12 characters, generated
independently of the secret. It is safe in logs and support tickets; the
secret's last four characters are what the console shows to tell keys apart.
Validate a key before you use it
A pasted key that lost a character fails the checksum. Checking it at start-up turns "every request is 401" into an error that names the problem:
- TypeScript
- Python
import { crc32 } from 'node:zlib'; // Node 20.15+ / 22.2+
const B62 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
const KEY = /^(cxc_(sk|pk|rk|mk)_(live|sandbox)_)([0-9A-Za-z]{32})([0-9A-Za-z]{6})$/;
function base62(n: number): string {
let s = '';
for (let i = 0; i < 6; i++) { s = B62[n % 62] + s; n = Math.floor(n / 62); }
return s;
}
// true if the string is a well-formed CashXChain key. Says nothing about
// whether the key exists or is still active — only the API can tell you that.
export function isWellFormedKey(key: string): boolean {
const m = KEY.exec(key);
return m !== null && base62(crc32(m[1] + m[4])) === m[5];
}
import re
import zlib
B62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
KEY = re.compile(r"(cxc_(sk|pk|rk|mk)_(live|sandbox)_)([0-9A-Za-z]{32})([0-9A-Za-z]{6})")
def _base62(n: int) -> str:
out = ""
for _ in range(6):
out = B62[n % 62] + out
n //= 62
return out
def is_well_formed_key(key: str) -> bool:
"""True if the string is a well-formed CashXChain key. Says nothing about
whether the key exists or is still active — only the API can tell you that."""
m = KEY.fullmatch(key)
return bool(m) and _base62(zlib.crc32((m[1] + m[4]).encode())) == m[5]
The CRC is the ordinary zlib/PNG CRC32, so any language's standard library
reproduces it. The API itself does not check the checksum: a malformed key is
simply an unknown key, 401. The example keys on this site fail the check on
purpose.
Older keys
Keys created before the cxc_ format — sk_sandbox_…, pk_sandbox_…,
rk_sandbox_…, mk_sandbox_… followed by 64 hex characters — still
authenticate, with the same rights. Keys are looked up by a hash of the whole
string, so the format they were minted in does not matter. The console and
GET /v1/api-keys mark them legacy: true. Their sandbox segment means
nothing: older keys carried it in every environment, so an sk_sandbox_…
key may be a production key — treat a leaked one as live. Replace them when
convenient: create a new key, deploy it, revoke the old one.
Authentication errors
Every authentication failure is 401 with the code unauthorized. The
message says which check failed:
message | Cause |
|---|---|
missing Authorization header | No header, or no Bearer prefix. On the session-only sandbox tools (adopt-corporate, /v1/sandbox/admin/*) a header without the prefix gets the next message instead. |
malformed Authorization header (expected `Bearer <jwt>`) | Bearer followed by nothing |
invalid JWT: api key not found or revoked | Unknown, mistyped, revoked, or other-environment key |
invalid JWT: master key not found or revoked | Same, for a master key |
invalid JWT: publishable keys are read-only and cannot call mutating endpoints | A pk_ key on a non-GET request to a developer route |
invalid JWT: InvalidToken | A key on a route that accepts sessions only, such as the sandbox's adopt-corporate |
One 401 carries a different code: key_not_admitted, when the money or
dashboard surface turns a key type away at the door — a pk_ key on
/v1/banking/*, or an sk_, rk_ or pk_ key on a dashboard route. Its
message names what the route admits.
The invalid JWT: prefix appears on key failures too; it is not a sign you
sent a JWT. Branch on the status and error, and log message.
Authorization failures
After authentication, a refusal is 403:
| Code | Cause |
|---|---|
insufficient_scope | A restricted key without the route's scope. details.required_scope names it and details.granted_scopes lists the key's. |
key_not_admitted | A key type the route does not admit: any API key on a people-only route, rk_/pk_ on request logs and usage, a publishable key on a route whose scope is not publishable. details.admits lists what is. |
route_not_available_to_api_keys | A route no API key may call at all. |
permission_denied | A dashboard session whose seat lacks the action's permission, or minting a key beyond it. |
account_not_verified | A money route on an account that is not verified — see KYB. |
forbidden | A public session token on an account route. |
RESOURCE_FORBIDDEN | Another account's resource on a dashboard route, or ?account_id= naming an account that is not yours — see Errors |
All codes are in the error reference.
Step-up authentication
Moving money from a dashboard session needs a second factor bound to the payment. API keys never need it — the key is the possession factor, and there is no person to prompt.
| Route | When a session needs X-Step-Up-Token | The token is bound to |
|---|---|---|
POST /v1/banking/payouts | Always | amount, currency, beneficiary_id |
POST /v1/banking/fx/execute | From 10,000 USD equivalent | sell amount, sell currency, buy currency |
A token is single-use, valid for five minutes, and refused if any bound value
differs from the request. Refusals: 401 two_factor_required,
401 step_up_expired, 403 step_up_invalid, 403 step_up_not_linked,
403 step_up_binding_mismatch, 403 step_up_already_used. If you integrate
with keys, none of these can happen to you.
Session tokens with a public audience
A sign-in token whose audience is public passes authentication but cannot act
for an account. Routes that resolve the caller's account answer
403 forbidden: "this endpoint is not available to public-API tokens — use a
Dashboard or admin token". Server-to-server integrations use keys and never
see this.
Checklist
- Keep
sk_andmk_keys in a secret manager, never in source control or client code. - One key per service. Revoking one should not take down the others.
- Use
rk_with the narrowest scopes that do the job; a service that only reads needsaccounts:readandpayments:read, nothing that pays. - Validate the key format at start-up.
- Never put a live key on a developer machine.
- If a key leaks, revoke it first and investigate second — Managing API keys.