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

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 needs position:read, sub-client:manage and vault: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"
Response
{
  "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"]
  }'
Response
{
  "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:

Response
{
  "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"
  }'
Response
{
  "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:

  • amount comes 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"
Response
{
  "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.

Webhook body
{
  "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:

HeaderCarries
tcb-webhook-idThe event id — use it to deduplicate.
tcb-webhook-eventThe event type.
tcb-webhook-signaturet=<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

Pilot disclosure

On this page