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.
| Rule | What it means for you |
|---|---|
| One sub-account per currency per account | An 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 IBAN | It 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 nothing | A 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 details | localRails 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 verified | iban.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 name | Never the account label. It is the name presented for payee-name checks where the receiving scheme runs them. |
| Two owners never share an IBAN | An IBAN belongs to one owner's accounts: yours, or one sub-client's. |
externalRef is unique per owner | Your 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.
| Field | Type | Required | Notes |
|---|---|---|---|
currency | string | yes | ISO 4217. |
label | string, up to 255 chars | yes | Your name for the account. Shown to you, never used as an IBAN holder name. Applied only when this call opens the account. |
externalRef | string, up to 128 chars | no | Your identifier, unique per owner. Must not start with tcb:. Applied only when this call opens the account. |
requestIban | boolean | no | No longer needed: an account this call opens always carries its IBAN. Accepted and ignored. |
idempotencyKey | string, up to 100 chars | no | Alternative 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.
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"
}'{
"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
| Call | Scope | Returns |
|---|---|---|
GET /accounts | account:read | A 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:read | One 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.
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.
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.
| Call | Field | What it selects | Refusal when more than one matches |
|---|---|---|---|
POST /deposits | sourceAccountId | The fiat account debited | 409 SOURCE_ACCOUNT_AMBIGUOUS |
POST /withdrawals | destinationAccountId | The fiat account credited | 409 DESTINATION_ACCOUNT_AMBIGUOUS |
POST /conversions | sourceAccountId | The account debited, in fromCurrency | 409 CONVERSION_ACCOUNT_AMBIGUOUS, details.side: "from" |
POST /conversions | targetAccountId | The account credited, in toCurrency | 409 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.
| Field | Type | Required | Notes |
|---|---|---|---|
fromAccountId | UUID | yes | The account to debit. Under x-on-behalf-of it must be one of that sub-client's own accounts. |
toAccountId | UUID | yes | The account to credit. Any account in your tree, in the same currency. |
amount | decimal string | yes | Greater than zero. No exponent, no thousands separator, no sign. |
currency | string | yes | The currency of both accounts. A mismatched pair is refused. |
reference | string, up to 140 chars | no | Your note, shown on both statements. |
metadata | object | no | Yours. Stored and returned untouched. |
idempotencyKey | string, up to 100 chars | no | Alternative 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.
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"
}'{
"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
| Call | Scope | Returns |
|---|---|---|
GET /transfers | transaction:read | A page of transfers, oldest first. Query accountId, from and to (ISO 8601 instants), cursor and limit (1–100, default 25). |
GET /transfers/{id} | transaction:read | One 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
| Status | Code | What to do |
|---|---|---|
| 400 | CURRENCY_NOT_SUPPORTED | Ask TCB which currencies your programme covers. |
| 400 | INVALID_AMOUNT | amount was not a positive decimal string, or a query value was out of range. |
| 400 | TRANSFER_CURRENCY_MISMATCH | The two accounts are in different currencies. Use a conversion first. |
| 400 | TRANSFER_SAME_ACCOUNT | fromAccountId and toAccountId are the same account. |
| 400 | TRANSFER_NON_LEDGER_ACCOUNT | One side is a connected external wallet, whose balance lives on-chain. Send to or from it on-chain instead. |
| 400 | TRANSFER_ASSET_NOT_SUPPORTED | One side is not a fiat account. This rail moves fiat only; move crypto on-chain instead. |
| 403 | INSUFFICIENT_SCOPE | The key lacks account:read, account:manage or transfer:execute. |
| 403 | ON_BEHALF_OF_NOT_ALLOWED_FOR_KEY | The key is restricted to a subset of your sub-clients and that one is outside it. |
| 404 | ACCOUNT_NOT_FOUND | No such account in the acting principal's scope. Acting for a sub-client, fromAccountId was not one of its accounts. |
| 404 | TRANSFER_NOT_FOUND | No transfer with that id has a leg in your scope. |
| 409 | ACCOUNT_EXTERNAL_REF_TAKEN | That reference is already used by one of your accounts or vault sub-accounts. |
| 409 | ACCOUNT_MAIN_EXISTS | You sent role to POST /accounts; remove it. |
| 409 | ACCOUNT_HAS_BALANCE | Move the balance out, then close. |
| 409 | ACCOUNT_HAS_OPEN_SUBS | Close the SUB accounts in that currency first. |
| 409 | ACCOUNT_MAIN_NOT_CLOSABLE | Your own MAIN is not closable through this API. |
| 409 | ACCOUNT_CLOSED | One side of the transfer is closed. details.accountId names it. |
| 409 | ACCOUNT_NOT_ACTIVE | One side is neither active nor closed — today that means frozen. details.accountId and details.status name it. |
| 409 | INSUFFICIENT_BALANCE | The debited account does not hold the amount. |
| 409 | TRANSFER_CAP_EXCEEDED | The debited sub-client's ceiling. details carries the kind, limit and attempted figures. |
| 409 | SUB_CLIENT_CAPABILITY_UNAVAILABLE | The debited owner lacks sendInternal, or the credited owner lacks receive. details.reason says which fix applies — see the reason table. |
| 422 | TRANSFER_NOT_WITHIN_TREE | One or both accounts sit outside your root. |
| 500 | TRANSFER_REPLAY_UNREADABLE | The 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.