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

Accounts & transfers

Open accounts under yourself or a sub-client, issue IBANs, and move money instantly inside your tree.

Concepts: The object model

An account holds money for exactly one owner: you, or one of your sub-clients. Balances live only on accounts. A sub-client's balances are never folded into yours.

The account tree

The solid blue boxes are currency accounts. All the currency accounts in one account receive on that account's one IBAN, and each also carries local details for its currency where the currency has them. The dashed outlines are your sub-clients — identities, not balances. Grey lines are structure.

RuleWhat it means for you
One sub-account per currency per accountAn account holds one currency account per currency. To hold the same currency separately, open another account: ask TCB to open one for you. POST /accounts opens a currency the owner does not hold yet and returns the one it already holds otherwise.
An account in a currency always has an IBANIt is there when the account opens. All the currency accounts in one account show the same IBAN in iban, and a payment on it lands in the currency account for the payment's currency.
A retired IBAN credits nothingA currency account that had an IBAN of its own moves onto its account's IBAN, and the old IBAN is retired. Money that arrives on a retired IBAN is not credited to any account: TCB holds it, and an operator releases it by hand.
An account may also have local detailslocalRails lists details on the currency's domestic scheme — SEPA for EUR, Fedwire / ACH for USD, Faster Payments / CHAPS for GBP, SIC for CHF — each with its bank, its numbers and the paymentReference to quote. beneficiaryName on local details is TCB's client trust account; the paymentReference is what identifies the account. JPY and BHD have none. Accounts opened before these details existed may list none.
A sub-client's IBANs are inactive until it is verifiediban.active and each localRails[].active are false while the sub-client is anything other than VERIFIED. Inactive details are not to be given out, are not entered into verification of payee, and accept no funds. Your own accounts' details are active.
The IBAN payee name is the owner's verified legal nameNever the account label. It is the name presented for payee-name checks where the receiving scheme runs them.
Two owners never share an IBANAn IBAN belongs to one owner's accounts: yours, or one sub-client's.
externalRef is unique per ownerYour own accounts share one namespace with your vault sub-accounts; each sub-client's accounts have a namespace of their own. The tcb: prefix is reserved.

capabilities on an account is its owner's current verification, projected onto the account: your own accounts carry receive, sendInternal and sendExternal true and holdSavings false, and holdCrypto / sendCrypto true when you hold crypto (your KYC is approved and you have an active crypto account); a sub-client's accounts carry that sub-client's capabilities, including sendExternal, which is not granted by default, and the two crypto flags, which follow yours by default.

A vault sub-account is a different kind of partition. It holds a share position and no fiat balance, GET /accounts never lists it, and no transfer can target it.

Open an account in a currency

POST /accounts · scope account:manage · idempotency key required

Opens the owner's account in currency and answers 201. If the owner already holds that currency, nothing is opened: the call answers 200 with the account it already holds, unchanged. Calling it twice never opens a second account in one currency.

FieldTypeRequiredNotes
currencystringyesISO 4217.
labelstring, up to 255 charsyesYour name for the account. Shown to you, never used as an IBAN holder name. Applied only when this call opens the account.
externalRefstring, up to 128 charsnoYour identifier, unique per owner. Must not start with tcb:. Applied only when this call opens the account.
requestIbanbooleannoNo longer needed: an account this call opens always carries its IBAN. Accepted and ignored.
idempotencyKeystring, up to 100 charsnoAlternative to the Idempotency-Key header.

There is no role field, and sending one is refused with 409 ACCOUNT_MAIN_EXISTS rather than ignored. Send x-on-behalf-of to open the account under a sub-client instead of under yourself.

Request
curl -X POST https://api.threecommabank.com/api/partner/v1/accounts \
  -H "Authorization: Bearer $TCB_API_KEY" \
  -H "Idempotency-Key: acme-account-2026-09-02-0001" \
  -H "Content-Type: application/json" \
  -d '{
    "currency": "USD",
    "label": "Payroll reserve",
    "externalRef": "acme:payroll-usd"
  }'
Response 201
{
  "success": true,
  "data": {
    "accountId": "4d80f3b6-9a17-4c25-8e93-0b6d1f7a3c58",
    "ownerType": "CLIENT",
    "ownerId": "b7a90c9d-3e14-4f02-9c65-1d8a2e6f4b07",
    "currency": "USD",
    "role": "MAIN",
    "label": "Payroll reserve",
    "externalRef": "acme:payroll-usd",
    "status": "ACTIVE",
    "balance": { "available": "0", "reserved": "0", "total": "0" },
    "iban": {
      "iban": "BH29TCBK00009876543210",
      "bic": "TCBKBHBMXXX",
      "holderName": "Acme Treasury Ltd",
      "active": true
    },
    "localRails": [
      {
        "rail": "Fedwire / ACH",
        "currency": "USD",
        "beneficiaryName": "Three Comma Bank - Client Trust Account",
        "bankName": "TCB Mock Partner Bank (US)",
        "bic": "TCBMUS00",
        "bankAddress": "New York, United States",
        "iban": null,
        "accountNumber": "4817250931",
        "routingNumber": "999999999",
        "sortCode": null,
        "paymentReference": "TCB-7K2M9QXA",
        "active": true
      }
    ],
    "capabilities": {
      "receive": true,
      "sendInternal": true,
      "sendExternal": true,
      "holdSavings": false,
      "holdCrypto": true,
      "sendCrypto": true
    },
    "createdAt": "2026-09-02T10:15:28.000Z",
    "disclosure": { "version": "2026-09-06", "summary": "…", "items": ["…"] }
  },
  "timestamp": "2026-09-02T10:15:28.402Z"
}

Every response on this page carries the same disclosure object printed at the top of this page. It is abbreviated in the examples.

balance is three decimal strings at the currency's own scale. On a crypto account, reserved is what a conversion or an outgoing payment from it holds back until it settles, and available is total minus that. On a fiat account today, reserved is "0" and available equals total, because fiat has no reservation lifecycle. Read all three rather than assuming two of them. When fiat reservations arrive, reserved starts carrying them and available starts differing from total, with no other change to this shape.

Read your accounts

CallScopeReturns
GET /accountsaccount:readA page of the acting principal's accounts. Query currency, role (MAIN or SUB), status (ACTIVE or CLOSED), cursor and limit (1–100, default 25). The body is { items, nextCursor }.
GET /accounts/{id}account:readOne account. An account in another tree — or a sub-client's account while you are not acting for that sub-client — answers 404 ACCOUNT_NOT_FOUND, never 403, so an id cannot be probed for existence.

Both accept x-on-behalf-of, which switches the acting principal to that sub-client so you read its accounts instead of yours.

GET /accounts includes every account you hold, not fiat alone — a crypto deposit address issues one of these too, and it appears here the same way your fiat accounts do, currency reporting the asset (for example USDC) instead of an ISO 4217 code. iban is null on it, and ?currency=USDC filters to it exactly as ?currency=EUR filters to a fiat account.

One kind of row is left out: where your onboarding provisioned a multi-asset container account, it holds no currency of its own and no balance of its own, so listing it would report an account with an empty currency and a zero balance. Every account that holds a currency of its own is listed.

Request
curl -G https://api.threecommabank.com/api/partner/v1/accounts \
  -H "Authorization: Bearer $TCB_API_KEY" \
  -H "x-on-behalf-of: c4f81e2a-9b60-4d37-a512-8e0f3c6b7d95" \
  --data-urlencode "currency=EUR" \
  --data-urlencode "role=MAIN"

Issue an IBAN

POST /accounts/{id}/iban · scope account:manage · idempotency key required in the header, because this route takes no body

Returns the whole account with its IBAN. Every account already has one from the moment it opens, so this call normally changes nothing; it issues an IBAN only for an account that somehow has none. Calling it again returns the same IBAN. The payee name is the owner's verified legal name. On a sub-client's account the IBAN comes back with active: false and stays inactive until that sub-client is VERIFIED.

Request
curl -X POST https://api.threecommabank.com/api/partner/v1/accounts/4d80f3b6-9a17-4c25-8e93-0b6d1f7a3c58/iban \
  -H "Authorization: Bearer $TCB_API_KEY" \
  -H "Idempotency-Key: acme-iban-2026-09-02-0001"

Close an account

POST /accounts/{id}/close · scope account:manage · idempotency key required in the header

The account must hold a zero balance. A MAIN cannot close while any SUB in that currency is still open, or while its owner is still active. Close the sub-client instead. Your own MAIN cannot be closed through this API at all. Returns the account with status: "CLOSED".

Name the right account on a money call

The deposit, withdrawal and conversion routes each take an optional account id. Omit it and TCB looks for exactly one usable active account of yours in that currency. One match is used. On two or more, the call is refused before any money moves. TCB never picks for you.

CallFieldWhat it selectsRefusal when more than one matches
POST /depositssourceAccountIdThe fiat account debited409 SOURCE_ACCOUNT_AMBIGUOUS
POST /withdrawalsdestinationAccountIdThe fiat account credited409 DESTINATION_ACCOUNT_AMBIGUOUS
POST /conversionssourceAccountIdThe account debited, in fromCurrency409 CONVERSION_ACCOUNT_AMBIGUOUS, details.side: "from"
POST /conversionstargetAccountIdThe account credited, in toCurrency409 CONVERSION_ACCOUNT_AMBIGUOUS, details.side: "to"

If you hold a currency in more than one account, the matching field becomes mandatory in practice for that currency. Name the account and the ambiguity is gone. A conversion resolves each side independently, so one side can be ambiguous while the other is not, and its 409 carries details.accountIds listing every candidate.

A conversion counts every active account of yours in the currency, including the account your crypto deposit address credits. A deposit or withdrawal counts fiat accounts only: the crypto-credited account is never picked for either, and naming it answers 400 SOURCE_ACCOUNT_NOT_USABLE or 400 DESTINATION_ACCOUNT_NOT_USABLE rather than being used.

Move money inside your tree

POST /transfers · scope transfer:execute · idempotency key required

A transfer moves money between two accounts under your root: your accounts, your sub-clients' accounts, or one of each. Both accounts must be fiat accounts holding the same currency — crypto does not move on this rail, and naming a crypto account on either side answers 400 TRANSFER_ASSET_NOT_SUPPORTED. The transfer is instant and terminal on the response, so there is no pending state to poll and no later state to wait for.

FieldTypeRequiredNotes
fromAccountIdUUIDyesThe account to debit. Under x-on-behalf-of it must be one of that sub-client's own accounts.
toAccountIdUUIDyesThe account to credit. Any account in your tree, in the same currency.
amountdecimal stringyesGreater than zero. No exponent, no thousands separator, no sign.
currencystringyesThe currency of both accounts. A mismatched pair is refused.
referencestring, up to 140 charsnoYour note, shown on both statements.
metadataobjectnoYours. Stored and returned untouched.
idempotencyKeystring, up to 100 charsnoAlternative to the Idempotency-Key header.

TCB applies no limits of its own to a movement inside your tree. The only ceilings are the ones you set yourself with caps on a sub-client, and they apply to that sub-client's CROSS_OWNER debits only.

The key is remembered permanently, as on the three vault routes. It is recorded against the transfer it posted, so a replay years later still returns that one transfer rather than posting a second. The same key with different parameters is still refused with 409 IDEMPOTENCY_KEY_REUSED. See Conventions.

Request
curl -X POST https://api.threecommabank.com/api/partner/v1/transfers \
  -H "Authorization: Bearer $TCB_API_KEY" \
  -H "Idempotency-Key: acme-transfer-2026-09-02-0001" \
  -H "Content-Type: application/json" \
  -d '{
    "fromAccountId": "1b9d4f07-6c23-4a8e-b510-9f2c7d3e5a68",
    "toAccountId": "4d80f3b6-9a17-4c25-8e93-0b6d1f7a3c58",
    "amount": "250.00",
    "currency": "USD",
    "reference": "Invoice 2026-0412"
  }'
Response 201
{
  "success": true,
  "data": {
    "transferId": "0c93a6e1-58fb-4d27-9a04-7e1b3c5d8f26",
    "status": "COMPLETED",
    "ownership": "SAME_OWNER",
    "from": {
      "accountId": "1b9d4f07-6c23-4a8e-b510-9f2c7d3e5a68",
      "ownerType": "CLIENT",
      "ownerId": "b7a90c9d-3e14-4f02-9c65-1d8a2e6f4b07"
    },
    "to": {
      "accountId": "4d80f3b6-9a17-4c25-8e93-0b6d1f7a3c58",
      "ownerType": "CLIENT",
      "ownerId": "b7a90c9d-3e14-4f02-9c65-1d8a2e6f4b07"
    },
    "amount": "250",
    "currency": "USD",
    "reference": "Invoice 2026-0412",
    "executedAt": "2026-09-02T10:15:30.000Z",
    "replayed": false,
    "disclosure": { "version": "2026-09-06", "summary": "…", "items": ["…"] }
  },
  "timestamp": "2026-09-02T10:15:30.147Z"
}

status is always COMPLETED. ownership is SAME_OWNER when both accounts belong to the same owner, and CROSS_OWNER otherwise. replayed is true when the body is the stored result of an earlier call under the same key. The money moved once.

x-on-behalf-of narrows the debit side only. Acting for a sub-client, fromAccountId must be one of that sub-client's accounts, while toAccountId may still be any account in your tree.

Read your transfers

CallScopeReturns
GET /transferstransaction:readA page of transfers, oldest first. Query accountId, from and to (ISO 8601 instants), cursor and limit (1–100, default 25).
GET /transfers/{id}transaction:readOne transfer in its terminal state.

Both are scoped the way GET /accounts is: transfers touching the acting principal's own accounts, on either leg. To read a movement between two of your sub-clients, act as one of them with x-on-behalf-of.

Keep transfers inside one tree

Both accounts must sit under your root client. A pair that does not is refused with 422 TRANSFER_NOT_WITHIN_TREE and nothing is posted. An account that does not exist at all answers identically, so a guessed id tells you nothing about anyone else's tree.

Handle the errors

StatusCodeWhat to do
400CURRENCY_NOT_SUPPORTEDAsk TCB which currencies your programme covers.
400INVALID_AMOUNTamount was not a positive decimal string, or a query value was out of range.
400TRANSFER_CURRENCY_MISMATCHThe two accounts are in different currencies. Use a conversion first.
400TRANSFER_SAME_ACCOUNTfromAccountId and toAccountId are the same account.
400TRANSFER_NON_LEDGER_ACCOUNTOne side is a connected external wallet, whose balance lives on-chain. Send to or from it on-chain instead.
400TRANSFER_ASSET_NOT_SUPPORTEDOne side is not a fiat account. This rail moves fiat only; move crypto on-chain instead.
403INSUFFICIENT_SCOPEThe key lacks account:read, account:manage or transfer:execute.
403ON_BEHALF_OF_NOT_ALLOWED_FOR_KEYThe key is restricted to a subset of your sub-clients and that one is outside it.
404ACCOUNT_NOT_FOUNDNo such account in the acting principal's scope. Acting for a sub-client, fromAccountId was not one of its accounts.
404TRANSFER_NOT_FOUNDNo transfer with that id has a leg in your scope.
409ACCOUNT_EXTERNAL_REF_TAKENThat reference is already used by one of your accounts or vault sub-accounts.
409ACCOUNT_MAIN_EXISTSYou sent role to POST /accounts; remove it.
409ACCOUNT_HAS_BALANCEMove the balance out, then close.
409ACCOUNT_HAS_OPEN_SUBSClose the SUB accounts in that currency first.
409ACCOUNT_MAIN_NOT_CLOSABLEYour own MAIN is not closable through this API.
409ACCOUNT_CLOSEDOne side of the transfer is closed. details.accountId names it.
409ACCOUNT_NOT_ACTIVEOne side is neither active nor closed — today that means frozen. details.accountId and details.status name it.
409INSUFFICIENT_BALANCEThe debited account does not hold the amount.
409TRANSFER_CAP_EXCEEDEDThe debited sub-client's ceiling. details carries the kind, limit and attempted figures.
409SUB_CLIENT_CAPABILITY_UNAVAILABLEThe debited owner lacks sendInternal, or the credited owner lacks receive. details.reason says which fix applies — see the reason table.
422TRANSFER_NOT_WITHIN_TREEOne or both accounts sit outside your root.
500TRANSFER_REPLAY_UNREADABLEThe key holds a completed transfer that cannot be read back. Nothing was posted by this request. Retry with the same key and use GET /transfers to see what actually posted.

The full catalogue is in Errors.

Receive the events

Subscribe at POST /webhooks to account.created, account.iban.issued, account.closed and transfer.completed. There is no transfer.failed — see which webhook fires when.

The machine-readable specification for these endpoints is openapi.partner.v1.json. Where it and this page disagree, the specification is authoritative.

Pilot disclosure

On this page