Quickstart
Six calls: check your key, register and activate an end customer, deposit into a vault, read your position, and receive the webhook.
New to the model? Read How it works first. It takes five minutes.
Before you start
You need:
- A
test_API key. TCB issues it. See Environments and API keys. For this walkthrough it needsposition:read,sub-client:manageandvault:deposit. - Two environment variables, so nothing below hard-codes a secret:
export TCB_API_BASE="https://api.threecommabank.com/api/partner/v1"
export TCB_API_KEY="test_..."Every response in this guide shows the full envelope. Every 2xx body is
{ "success": true, "data": …, "timestamp": … }. Your payload is always under
data.
Check your key
GET /me reads your own principal and moves no money. Call it to confirm the
key works and to read which scopes it holds.
curl "$TCB_API_BASE/me" \
-H "Authorization: Bearer $TCB_API_KEY"{
"success": true,
"data": {
"partnerId": "9f1c2a54-7b3d-4e08-9a61-2c5f0b7d4e13",
"name": "Acme Payments",
"principalType": "PARTNER",
"environment": "test",
"scopes": ["position:read", "sub-client:manage", "vault:deposit", "transaction:read"],
"keyPrefix": "test_A7fQ2xM",
"clientAccountId": "3a7e5c91-0d24-4b6f-8e13-5c9a2f7b1d04",
"subClientId": null,
"subtreeRestricted": false
},
"timestamp": "2026-09-02T10:15:28.114Z"
}If you get a 401, the key is wrong, revoked or expired. If you get a 403
with INSUFFICIENT_SCOPE, the key is valid but does not hold position:read.
Create a sub-client
A sub-client is one of your end customers. Each sub-client holds its own
account with TCB, in its own name. You collect the identity information and TCB
accepts it. You instruct on the sub-client's behalf. Four fields are required: kind, your own externalRef, the verified
legalName, and at least one currency. Each currency gets a MAIN account with
an IBAN, reserved atomically — and inactive until the sub-client is verified.
This route requires an Idempotency-Key.
curl -X POST "$TCB_API_BASE/sub-clients" \
-H "Authorization: Bearer $TCB_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: acme-subclient-2026-09-02-0001" \
-d '{
"kind": "person",
"externalRef": "acme:cust-04231",
"legalName": "Fatima Al-Khalifa",
"currencies": ["EUR"]
}'{
"success": true,
"data": {
"subClientId": "c4f81e2a-9b60-4d37-a512-8e0f3c6b7d95",
"kind": "person",
"externalRef": "acme:cust-04231",
"legalName": "Fatima Al-Khalifa",
"status": "ACTIVE",
"caps": {},
"verification": {
"level": "RELIANCE",
"status": "REQUIREMENTS_DUE",
"requirements": {
"currentlyDue": [
"identity.person.dateOfBirth",
"identity.person.nationality",
"identity.person.address",
"identity.person.nationalId",
"attestation",
"screening"
],
"pastDue": [],
"eventuallyDue": [],
"disabledReason": null
},
"capabilities": {
"receive": false,
"sendInternal": false,
"sendExternal": false,
"holdSavings": false,
"holdCrypto": false,
"sendCrypto": false
},
"attestedAt": null,
"verifiedAt": null
},
"accounts": [
{
"accountId": "7e2a0c53-4d81-49b6-8f37-1a5c9e0b2d74",
"ownerType": "SUB_CLIENT",
"ownerId": "c4f81e2a-9b60-4d37-a512-8e0f3c6b7d95",
"currency": "EUR",
"role": "MAIN",
"status": "ACTIVE",
"balance": { "available": "0", "reserved": "0", "total": "0" },
"iban": {
"iban": "BH29TCBK00001234567890",
"bic": "TCBKBHBMXXX",
"holderName": "Fatima Al-Khalifa",
"active": false
},
"capabilities": {
"receive": false,
"sendInternal": false,
"sendExternal": false,
"holdSavings": false,
"holdCrypto": false,
"sendCrypto": false
}
}
],
"createdAt": "2026-09-02T10:16:02.000Z",
"updatedAt": "2026-09-02T10:16:02.000Z",
"disclosure": { "…": "…" }
},
"timestamp": "2026-09-02T10:16:02.331Z"
}A minimal body is accepted and lands the sub-client in REQUIREMENTS_DUE.
Nothing is live: every capability is false, and iban.active is false, so
that coordinate must not be given out and accepts no funds.
requirements.currentlyDue names exactly what is still missing.
Activate it
Send what currentlyDue asked for. Here that is the rest of the identity
record and an attestation of the due diligence and screening you performed.
Patch the identity first, then append the attestation. "screening" is also
on that list, but it is TCB's own check — there is nothing to send for it, and
it clears on its own once the rest is in.
SUB_CLIENT_ID="c4f81e2a-9b60-4d37-a512-8e0f3c6b7d95"
curl -X PATCH "$TCB_API_BASE/sub-clients/$SUB_CLIENT_ID" \
-H "Authorization: Bearer $TCB_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"identity": {
"dateOfBirth": "1988-04-17",
"nationality": "BH",
"address": { "line1": "14 Road 2825", "city": "Manama", "country": "BH" },
"nationalId": "880417012"
}
}'
curl -X POST "$TCB_API_BASE/sub-clients/$SUB_CLIENT_ID/attestations" \
-H "Authorization: Bearer $TCB_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: acme-attest-2026-09-02-0001" \
-d '{
"text": "We have performed customer due diligence and screening on this customer under our agreed reliance programme.",
"version": "reliance-2026-09",
"cddPerformedAt": "2026-08-28T09:00:00.000Z",
"cddStandard": "standard",
"verificationMethod": "documentary",
"verificationResult": "verified",
"screeningResult": "clear",
"verifierRef": "acme-case-88214",
"pepStatus": "none",
"riskRating": "low"
}'Read the sub-client back with GET /sub-clients/{id}. TCB runs its own
screening at activation, so the status is what tells you the sub-client is
live:
{
"success": true,
"data": {
"subClientId": "c4f81e2a-9b60-4d37-a512-8e0f3c6b7d95",
"verification": {
"level": "RELIANCE",
"status": "VERIFIED",
"requirements": {
"currentlyDue": [],
"pastDue": [],
"eventuallyDue": [],
"disabledReason": null
},
"capabilities": {
"receive": true,
"sendInternal": true,
"sendExternal": false,
"holdSavings": false,
"holdCrypto": true,
"sendCrypto": true
},
"attestedAt": "2026-09-02T10:16:40.000Z",
"verifiedAt": "2026-09-02T10:16:40.000Z"
},
"accounts": [
{
"accountId": "7e2a0c53-4d81-49b6-8f37-1a5c9e0b2d74",
"currency": "EUR",
"role": "MAIN",
"iban": {
"iban": "BH29TCBK00001234567890",
"bic": "TCBKBHBMXXX",
"holderName": "Fatima Al-Khalifa",
"active": true
},
"capabilities": {
"receive": true,
"sendInternal": true,
"sendExternal": false,
"holdSavings": false,
"holdCrypto": true,
"sendCrypto": true
}
}
],
"disclosure": { "…": "…" }
},
"timestamp": "2026-09-02T10:16:41.204Z"
}Now the IBAN is live: active is true, it can be given out and entered into
verification of payee, and it accepts funds. sendExternal stays false
unless TCB granted it for this sub-client. The
sub-clients guide has the whole activation
baseline and every attestation field.
Deposit into a vault
POST /deposits debits one of your fiat accounts and buys vault shares. If you
name no sub-account, the shares land in your default sub-account
(tcb:default), which every client holds from day one.
curl -X POST "$TCB_API_BASE/deposits" \
-H "Authorization: Bearer $TCB_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: acme-deposit-2026-09-02-0001" \
-d '{
"currency": "USD",
"amount": "1000.00"
}'{
"success": true,
"data": {
"depositId": "2f6c9a41-3e58-4b70-9d12-6a8f0c3e5b71",
"status": "SETTLED",
"idempotencyKey": "acme-deposit-2026-09-02-0001",
"replayed": false,
"currency": "USD",
"amount": "1000",
"vault": {
"address": "0x7a2fD3c8e91B4A5d06E7c1F2b38A9d4C5e6f0B12",
"chain": "ARBITRUM"
},
"custody": {
"mode": "TCB_SUBACCOUNT",
"walletAddress": "0x3B9aC4f27D18E056b1C4a9F30d2E85c7A6b1F094"
},
"shares": { "balance": "1024.518330000000000000000000" },
"onchain": {
"depositTxHash": "0x4a1f9c07d3b28e615a0c47f8d29b3e6c15087af2d94b6c308e17b5d02fa4c396",
"mintTxHash": "0x9d20b7e548c1f36a0742e9bd35c108af62d497130ba85e6c2f9d40718ac35be2"
},
"settledAt": "2026-09-02T10:17:41.000Z",
"createdAt": "2026-09-02T10:17:34.000Z",
"disclosure": { "…": "…" }
},
"timestamp": "2026-09-02T10:17:41.902Z"
}status: "SETTLED" means the shares are yours. Note two things about the
amounts:
amountcomes back normalised. A submitted"1000.00"is returned as"1000".- Every amount on this API is a decimal string, never a JSON number.
If you hold more than one USD account, this call is refused as ambiguous before
anything is charged. Name the account to debit with sourceAccountId.
Read your position
GET /positions requires a currency, which selects the vault. It returns your
whole position and the same figures broken down by sub-account.
curl "$TCB_API_BASE/positions?currency=USD" \
-H "Authorization: Bearer $TCB_API_KEY"{
"success": true,
"data": {
"currency": "USD",
"vault": {
"address": "0x7a2fD3c8e91B4A5d06E7c1F2b38A9d4C5e6f0B12",
"chain": "ARBITRUM"
},
"totals": {
"settledShares": "1024.518330000000000000000000",
"reservedShares": "0",
"availableShares": "1024.518330000000000000000000",
"walletShares": "1024.518330000000000000000000",
"accrual": { "…": "…" }
},
"subAccounts": [
{
"subAccountId": "a0c74e19-6b83-4d51-9f2a-7c3e8b1d0462",
"label": "Default",
"externalRef": "tcb:default",
"isDefault": true,
"settledShares": "1024.518330000000000000000000",
"reservedShares": "0",
"availableShares": "1024.518330000000000000000000",
"accrual": { "…": "…" }
}
],
"disclosure": { "…": "…" }
},
"timestamp": "2026-09-02T10:18:05.447Z"
}Read it again tomorrow: the share count is identical and its value has moved. The share count does not grow; the share price rises. See Positions for what each figure means.
Receive the webhook
Register an endpoint once with POST /webhooks, and TCB posts to it whenever a
money call reaches a terminal state. The deposit above fires deposit.settled.
Payloads are thin: an event id, a type, a timestamp and a pointer to the object. They never carry a snapshot of it.
{
"id": "9c1e5b3a-7d24-4f80-b6a1-2e5c9017a4f6",
"type": "deposit.settled",
"occurredAt": "2026-09-02T10:17:41.000Z",
"resource": {
"type": "deposit",
"id": "2f6c9a41-3e58-4b70-9d12-6a8f0c3e5b71"
},
"disclosure": { "…": "…" }
}Fetch the object with GET /deposits/{id}, using resource.id. Three headers
arrive with every delivery:
| Header | Carries |
|---|---|
tcb-webhook-id | The event id — use it to deduplicate. |
tcb-webhook-event | The event type. |
tcb-webhook-signature | t=<unix seconds>,v1=<hex> — verify this before you trust anything in the body. |
Verify the signature before you act
An unverified webhook is an unauthenticated HTTP request from the internet. The webhooks guide has the verification code in Node and Python, plus retries and secret rotation. Do not ship without it.
Retry with the same idempotency key
Every money call needs an Idempotency-Key that you choose. On a timeout, a
dropped connection or a 5xx, retry with the same key. Resending it
replays the original result without moving money again. A fresh key is a second
deposit. The full rule is in
Conventions.
Next
How it works
The object model: sub-clients, accounts, vault sub-accounts and positions.
The money lifecycle
Every state a deposit, withdrawal or conversion can be in, and what to do about each.
Sub-clients
The activation baseline, attestations, capabilities and transfer caps in full.
Conventions
Idempotency, rate limits, amounts, pagination and versioning.
Pilot disclosure
Overview
An HTTP API for institutions that hold money for their own clients: accounts and IBANs, fiat and crypto in, payouts out, and a yield-bearing position in a curated vault.
Environments and API keys
The pilot base URL, what is real and what is simulated in it, and how your API keys are issued, stored, rotated and revoked.