Skip to main content

Authentication

Every request carries one credential as a bearer token in the Authorization header:

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

CredentialPrefixIssued byUse it for
Secret keycxc_sk_You, in the console or via the APIServer-side integrations. The key that moves money.
Restricted keycxc_rk_YouA 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 keycxc_pk_YouRead-only calls from code you do not control, such as a browser. Today that is indicative FX rates and nothing else.
Master keycxc_mk_CashXChain, on requestPlatforms that act for businesses they onboarded. See Platforms.
Dashboard sessiona JWTThe sign-in flowThe 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.

SurfaceRoutesSessionmk_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✓✓✓✓ scopedGET /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 toolsThe 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:

RouteWho may call itOthers 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/accountsSession, mk_, sk_, rk_ with accounts:read403 key_not_admitted for pk_
POST /v1/customersCashXChain staff403 key_not_admitted for every API key
Collect onboarding and group payments, billing, usernames, the pay page, grantsSession, 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.

Least privilege on the money API

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

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

ScopeOpens
accounts:readGET /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:writePOST /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:writePOST /v1/banking/beneficiaries, …/{beneficiary_id}/verify, …/{beneficiary_id}/accept-verification
payments:readGET /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:writePOST /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:readGET /v1/fx/rates, POST /v1/banking/fx/quote
collect:readGET /v1/collect/status, GET /v1/collect/payment-links, …/{slug}/payments, GET /v1/collect/payments, GET /v1/collect/refunds
collect:writePOST /v1/collect/payment-links, PATCH …/{slug}, POST /v1/collect/payments
refunds:writePOST /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:

PermissionGatesOwner, adminMemberViewer, api_consumer
payouts.createPOST /v1/banking/payouts✓✓—
fx.executePOST /v1/banking/fx/execute✓✓—
collect.manageCollect onboarding: POST /v1/collect/accounts, …/account-session✓——
collect.links.createThe 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​

Plain text
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 (live or sandbox) 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
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];
}

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:

messageCause
missing Authorization headerNo 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 revokedUnknown, mistyped, revoked, or other-environment key
invalid JWT: master key not found or revokedSame, for a master key
invalid JWT: publishable keys are read-only and cannot call mutating endpointsA pk_ key on a non-GET request to a developer route
invalid JWT: InvalidTokenA 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:

CodeCause
insufficient_scopeA restricted key without the route's scope. details.required_scope names it and details.granted_scopes lists the key's.
key_not_admittedA 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_keysA route no API key may call at all.
permission_deniedA dashboard session whose seat lacks the action's permission, or minting a key beyond it.
account_not_verifiedA money route on an account that is not verified — see KYB.
forbiddenA public session token on an account route.
RESOURCE_FORBIDDENAnother 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.

RouteWhen a session needs X-Step-Up-TokenThe token is bound to
POST /v1/banking/payoutsAlwaysamount, currency, beneficiary_id
POST /v1/banking/fx/executeFrom 10,000 USD equivalentsell 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_ and mk_ 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 needs accounts:read and payments: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.