Pilot environment: testnet, simulated yield, mocked fiat rails — read the disclosure
Getting started

Authentication

Send your API key as a bearer token, understand what its scopes allow, and act on behalf of one of your sub-clients.

Send the key as a bearer token

Every request carries your API key in the Authorization header. That is the whole credential. There is no session, no cookie, no signature and no second header to derive.

Request
curl "$TCB_API_BASE/me" \
  -H "Authorization: Bearer $TCB_API_KEY"

The header must be exactly Bearer, one space, then the key. A missing header, a different scheme or extra whitespace returns 401.

Scopes

Your key carries a fixed list of scopes. A route checks for exactly one. A key that does not hold it is refused with 403 and code: "INSUFFICIENT_SCOPE" before the handler runs. Read your own list from GET /me, and ask TCB to change it.

ScopeRoutes it unlocks
position:readGET /me, GET /sub-accounts, GET /positions, GET /positions/share-price-history
account:readGET /accounts, GET /accounts/{id}
account:managePOST /accounts, POST /accounts/{id}/iban, POST /accounts/{id}/close, POST /sub-accounts, POST /crypto-deposit-addresses
transaction:readGET /deposits, GET /deposits/{id}, GET /withdrawals, GET /withdrawals/{id}, GET /conversions/{id}, GET /payouts/{id}, GET /transfers, GET /transfers/{id}, GET /crypto-deposits, GET /crypto-deposits/{id}
vault:depositPOST /deposits
vault:withdrawPOST /withdrawals
trade:executePOST /conversions
payment:executePOST /payouts
transfer:executePOST /transfers
settlement:readGET /settlements, GET /settlements/{id}, GET /settlement-requests, GET /settlement-requests/{id}, GET /settlement-holds, GET /settlement-holds/{id}, GET /settlement-returns, GET /settlement-returns/{id}, GET /network/profile, GET /network/resolve, GET /network/search, GET /network/connections, GET /netting/agreements, GET /netting/agreements/{id}, GET /netting/agreements/{id}/obligations, GET /netting/agreements/{id}/cuts, GET /netting/obligations/{id}
settlement:managePUT /network/profile, POST /network/connections, DELETE /network/connections/{memberClientAccountId}, POST /settlement-requests, POST /settlement-requests/{id}/decline, POST /settlement-requests/{id}/cancel, POST /settlement-holds, POST /settlement-holds/{id}/cancel, POST /settlements/{id}/return-request, POST /settlement-returns/{id}/decline, POST /settlement-returns/{id}/cancel, POST /netting/agreements, POST /netting/agreements/{id}/accept, POST /netting/agreements/{id}/decline, POST /netting/agreements/{id}/end, POST /netting/obligations/{id}/withdraw
settlement:executePOST /settlements, POST /settlement-requests/{id}/approve, POST /settlement-holds/{id}/release, POST /settlement-returns/{id}/accept, POST /netting/agreements/{id}/obligations, POST /netting/agreements/{id}/cut
statement:readGET /statements, GET /statements/{id}
sub-client:readGET /sub-clients, GET /sub-clients/{id}, GET /sub-clients/{id}/identity, GET /sub-clients/{id}/attestations
sub-client:managePOST /sub-clients, PATCH /sub-clients/{id}, POST /sub-clients/{id}/attestations, POST /sub-clients/{id}/currencies, POST /sub-clients/{id}/close
payee:readGET /beneficiaries, GET /beneficiaries/{id}
payee:managePOST /beneficiaries, DELETE /beneficiaries/{id}
webhook:manageGET /webhooks, POST /webhooks, GET /webhooks/{id}, DELETE /webhooks/{id}, POST /webhooks/{id}/rotate-secret

Ask for the narrowest set that does the job. A reporting process that never moves money should not be holding vault:withdraw.

Acting on behalf of a sub-client

Once you hold sub-clients, you can execute a request as one of them by naming it in a header:

x-on-behalf-of: c4f81e2a-9b60-4d37-a512-8e0f3c6b7d95

The request then reads and writes that sub-client's own accounts and registry rows rather than yours. Your key's scopes still authorise the request. The header changes whose data the call touches, never what the key may do.

What happens when you send it

IfThen
You omit the headerThe request acts as you, against your own accounts and rows.
You name one of your own open sub-clients, on a route that accepts the headerThe request executes as that sub-client.
You name a sub-client on a route that does not accept the header400 ON_BEHALF_OF_NOT_ALLOWED_HERE. The header is refused, never ignored.
You name an id that is not one of your sub-clients, does not exist at all, or is malformed404 ON_BEHALF_OF_NOT_FOUND. All three answer identically, so an id cannot be probed.
You name a closed sub-client409 ON_BEHALF_OF_SUB_CLIENT_CLOSED, on reads as well as writes. Its accounts are retired.
Your key is restricted to a subset of your sub-clients and you name one outside it403 ON_BEHALF_OF_NOT_ALLOWED_FOR_KEY. Only a restricted key ever sees this.
The sub-client exists and is open, but its verification does not yet permit the flow409 SUB_CLIENT_CAPABILITY_UNAVAILABLE. Fix that sub-client's verification, not your key.
You send the same Idempotency-Key with a different x-on-behalf-of409 IDEMPOTENCY_KEY_REUSED. The header is part of the request fingerprint.

Two further rules apply:

  • The header cannot nest. A sub-client never has sub-clients of its own, so a request that is already acting as one cannot act as another.
  • Your webhook subscriptions are unaffected. A sub-client holds no credential and no subscription of its own, so events about one are delivered to yours.

Which routes accept it

Accepts x-on-behalf-ofRefuses it
GET /mePOST /deposits, GET /deposits, GET /deposits/{id}
GET /accounts, GET /accounts/{id}, POST /accounts, POST /accounts/{id}/iban, POST /accounts/{id}/closePOST /withdrawals, GET /withdrawals, GET /withdrawals/{id}
GET /sub-clients, GET /sub-clients/{id}, PATCH /sub-clients/{id}, GET /sub-clients/{id}/identity, GET /sub-clients/{id}/attestations, POST /sub-clients/{id}/attestations, POST /sub-clients/{id}/currencies, POST /sub-clients/{id}/closePOST /conversions, GET /conversions/{id}
GET /transfers, GET /transfers/{id}, POST /transfersPOST /sub-accounts, GET /sub-accounts, GET /positions, GET /positions/share-price-history
POST /payouts, GET /payouts/{id}GET /statements, GET /statements/{id}
Every settlement route: everything under /settlements, /settlement-requests, /settlement-holds, /settlement-returns, /network and /nettingPOST /sub-clients, the /webhooks registry, the /beneficiaries registry

The vault routes refuse the header: a position cannot be attributed to a sub-client through this contract. Use a vault sub-account to partition your position instead.

A worked request

Read one of your sub-client's accounts, as that sub-client:

curl "$TCB_API_BASE/accounts" \
  -H "Authorization: Bearer $TCB_API_KEY" \
  -H "x-on-behalf-of: c4f81e2a-9b60-4d37-a512-8e0f3c6b7d95"

Confirm who you are about to act as

GET /me accepts the header, so you can check which principal a call would run as before you call anything that moves money:

Response
{
  "success": true,
  "data": {
    "partnerId": "9f1c2a54-7b3d-4e08-9a61-2c5f0b7d4e13",
    "name": "Acme Payments",
    "principalType": "SUB_CLIENT",
    "environment": "test",
    "scopes": ["position:read", "transaction:read", "vault:deposit", "vault:withdraw"],
    "keyPrefix": "test_A7fQ2xM",
    "clientAccountId": "3a7e5c91-0d24-4b6f-8e13-5c9a2f7b1d04",
    "subClientId": "c4f81e2a-9b60-4d37-a512-8e0f3c6b7d95",
    "subtreeRestricted": false
  },
  "timestamp": "2026-09-02T10:15:28.114Z"
}

clientAccountId stays your own root whatever the header says. subtreeRestricted tells you whether this key may act for every one of your sub-clients or only some. A wrong id and a restricted key are therefore distinguishable.

Authentication errors

StatusCodeMeaning
401—Missing Authorization header, or a malformed one.
401—Unknown, revoked or expired key. All three read identically, so probing cannot tell them apart.
401—Your principal is not active. Contact TCB.
403INSUFFICIENT_SCOPEThe key does not hold the scope this route requires. The body names the scope.
403—The key's principal type is not permitted on this endpoint.
429MACHINE_RATE_LIMITEDYou exceeded your per-principal rate limit. See Conventions.

Pilot disclosure

On this page