Vault deposits & withdrawals
Place fiat you hold into a curated vault and redeem it back out again — a vault position, not a deposit held at a bank.
Concepts: Intents and the money lifecycle
A deposit turns fiat you hold at TCB into a position in the curated vault for that currency. A withdrawal redeems part of that position back into one of your fiat accounts. Both are synchronous: the response is the outcome.
Both routes act as you, never as a sub-client. They refuse x-on-behalf-of with 400 ON_BEHALF_OF_NOT_ALLOWED_HERE.
Each call runs the whole tokenised deposit chain at once: a deposit mints tcbUSD from your fiat and puts it into the vault, and a withdrawal reverses both. To run one step at a time — to hold tcbUSD between them, or to queue a redemption the vault cannot pay right now — use Tokenised deposits, step by step. These two calls are unchanged by it.
Every money call needs an idempotency key, and a key is not reusable
Send it as the Idempotency-Key header or as the idempotencyKey body field, and replay it on every retry of the same movement. Never mint a new key on a retry path: a new key is a second deposit or a second redemption. On these routes the key is remembered permanently. The full rule, including how parameters are compared, is in Conventions.
Follow the state ladder
Both flows walk the same six states. GET /deposits/{id} and GET /withdrawals/{id} report the current one.
| Status | What it means | What to do |
|---|---|---|
PENDING | Accepted, nothing irreversible has happened | Wait or poll. |
EXECUTING_ONCHAIN | The legs are in flight | Wait or poll. |
SETTLED | Terminal. Deposit: your shares are minted. Withdrawal: your fiat is credited. | Done. |
FAILED | Terminal. Nothing was charged and nothing moved. | Safe to retry under a new key. |
FAILED_PENDING_COMPENSATION | Money moved and the unwind is not confirmed | TCB resolves it — automatically where the outcome can be proven, by an operator otherwise. Do not retry. |
COMPENSATED | Terminal. TCB resolved the compensation. | Read the object for the outcome. |
The full ladder, including which webhook fires on each terminal transition, is in The money lifecycle.
Never re-key a compensable failure
500 DEPOSIT_FAILED_PENDING_COMPENSATION, 500 WITHDRAWAL_FAILED_PENDING_COMPENSATION and the two *_SETTLEMENT_NOT_RECORDED codes all mean the same thing for your integration: money moved. Retrying with a fresh idempotency key starts a second deposit of your fiat, or a second irreversible redemption of your shares.
TCB resolves these — automatically where the outcome can be proven, by an operator otherwise — and nothing resolves them on a timer. Poll the original key or the resource id. The answer will not change until it has been resolved: a compensable record keeps returning the same failure, and a settlement that was not recorded keeps returning 409 IDEMPOTENT_REQUEST_IN_PROGRESS even though the money completed. Quote the depositId or withdrawalId from details when you contact TCB.
Deposit fiat into a vault
POST /deposits · scope vault:deposit · idempotency key required
Debits your fiat account, mints the vault's underlying and deposits it into the active vault for that currency, holding the shares in the wallet TCB custodies for you. Your first deposit creates that wallet before anything is charged.
| Field | Type | Required | Notes |
|---|---|---|---|
currency | string, 3 letters | yes | Selects the active vault for that currency. |
amount | decimal string | yes | Never a JSON number. No more fractional digits than the currency settles at — USD settles at 2. Amounts below 0.000001 are refused. |
sourceAccountId | UUID | no | The fiat account to debit. Omitted, your single active account in that currency is used — see naming the right account. The account your crypto deposit address credits is refused here: convert it first. |
subAccountId | UUID | no | The vault sub-account to credit. Omitted, your default sub-account is used. It partitions your own book and does not change where the shares are custodied. |
idempotencyKey | string, up to 100 chars | no | Alternative to the Idempotency-Key header. |
subAccountId enters the idempotency fingerprint only when you state it, so a key issued before you started naming sub-accounts still replays unchanged.
curl -X POST https://api.threecommabank.com/api/partner/v1/deposits \
-H "Authorization: Bearer $TCB_API_KEY" \
-H "Idempotency-Key: acme-deposit-2026-09-02-0001" \
-H "Content-Type: application/json" \
-d '{
"currency": "USD",
"amount": "1000.00",
"sourceAccountId": "1b9d4f07-6c23-4a8e-b510-9f2c7d3e5a68",
"subAccountId": "5d3b8e10-2f47-4c9a-b6e8-0a71d4c2f395"
}'{
"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", "minted": "1024.518330000000000000000000" },
"onchain": {
"depositTxHash": "0x4a1f9c07d3b28e615a0c47f8d29b3e6c15087af2d94b6c308e17b5d02fa4c396",
"mintTxHash": "0x9d20b7e548c1f36a0742e9bd35c108af62d497130ba85e6c2f9d40718ac35be2"
},
"settledAt": "2026-09-02T10:15:34.000Z",
"createdAt": "2026-09-02T10:15:28.000Z",
"disclosure": { "version": "2026-09-06", "summary": "…", "items": ["…"] }
},
"timestamp": "2026-09-02T10:15:34.271Z"
}Every response on this page carries the same disclosure object printed at the top of this page. It is abbreviated in the examples.
Read three fields carefully:
amountis normalised, not echoed. A submitted"1000.00"comes back as"1000".shares.balanceis the sub-account's whole share balance after this deposit, as a decimal string at the vault's own share scale. It isnullwhen the balance could not be read. A settled deposit is never failed over an unavailable read, so retry the fetch, not the deposit. There is no per-deposit minted-shares figure.custody.modeisTCB_SUBACCOUNT: TCB holds the shares for you, in the TCB wallet assigned to you. You hold no keys; the sub-account's shares are a book entry against what that wallet holds.
Withdraw fiat from a vault
POST /withdrawals · scope vault:withdraw · idempotency key required
Redeems shares back into fiat and credits one of your accounts. You ask for an amount of fiat, the same way you deposit one. You never name a number of shares.
| Field | Type | Required | Notes |
|---|---|---|---|
currency | string, 3 letters | yes | Selects the vault and the settlement currency. |
amount | decimal string | yes | No more fractional digits than the currency settles at — a finer amount would be rounded down on settlement and the remainder left behind, so it is refused instead. Amounts below 0.000001 are refused. |
destinationAccountId | UUID | no | The fiat account to credit. Omitted, your single active account in that currency is used — see naming the right account. The account your crypto deposit address credits cannot receive a withdrawal. |
subAccountId | UUID | no | The vault sub-account to withdraw from. Omitted, your default sub-account is used. |
idempotencyKey | string, up to 100 chars | no | Alternative to the Idempotency-Key header. |
What you can withdraw
A withdrawal is authorised against the named sub-account alone:
available = settled − reservedsettled— what the sub-account holds, as recorded on your ledger.reserved— shares held against your own withdrawals that are still in flight from that sub-account.available— what a new withdrawal may take. It is the only figure checked. A balance you hold in another sub-account is not available here.
The check, the claim on your idempotency key and the reserve are one atomic step, so two concurrent withdrawals can never spend the same shares. The reserve is released when the withdrawal settles. A withdrawal that ends compensable keeps its reserve held until TCB resolves it, and nothing releases a reserve because time passed. Read all three figures on GET /positions.
curl -X POST https://api.threecommabank.com/api/partner/v1/withdrawals \
-H "Authorization: Bearer $TCB_API_KEY" \
-H "Idempotency-Key: acme-withdrawal-2026-09-02-0001" \
-H "Content-Type: application/json" \
-d '{
"currency": "USD",
"amount": "250.00",
"destinationAccountId": "1b9d4f07-6c23-4a8e-b510-9f2c7d3e5a68",
"subAccountId": "5d3b8e10-2f47-4c9a-b6e8-0a71d4c2f395"
}'{
"success": true,
"data": {
"withdrawalId": "8c1e7b25-4a93-4f06-b83d-51a0d7c46e29",
"status": "SETTLED",
"idempotencyKey": "acme-withdrawal-2026-09-02-0001",
"replayed": false,
"currency": "USD",
"amount": "250",
"settledAmount": "250",
"precisionResidue": "0.004821",
"vault": {
"address": "0x7a2fD3c8e91B4A5d06E7c1F2b38A9d4C5e6f0B12",
"chain": "ARBITRUM"
},
"custody": {
"mode": "TCB_SUBACCOUNT",
"walletAddress": "0x3B9aC4f27D18E056b1C4a9F30d2E85c7A6b1F094"
},
"shares": { "balance": "774.518330000000000000000000" },
"onchain": {
"withdrawTxHash": "0x6c58e2af017d94b3182fa0e7c53d69b148027ea5f31c8d60947b25e0fa1c3d78",
"burnTxHash": "0xb31d0f6a29c85e470132d9af6058c7be24913f5d7ea0c86241b09fd35c7a1e64"
},
"settledAt": "2026-09-02T10:15:41.000Z",
"createdAt": "2026-09-02T10:15:28.000Z",
"disclosure": { "version": "2026-09-06", "summary": "…", "items": ["…"] }
},
"timestamp": "2026-09-02T10:15:41.336Z"
}Three fields have no deposit counterpart. All three describe one fact: what you asked for and what settles can differ by the rounding to the currency's own precision.
| Field | Meaning |
|---|---|
amount | What you requested, normalised. |
settledAmount | What the rounding determined is owed. null until the vault leg lands. It is not proof of delivery on its own. Pair it with onchain.burnTxHash, which is written only once the fiat leg landed. |
precisionResidue | What the vault returned above settledAmount. It stays in the wallet TCB custodies for you. It is your money, it is not a deduction, and TCB does not keep it. |
shares.balance is the sub-account's remaining share balance, at the vault's own share scale, or null when the read was unavailable. There is no per-withdrawal burned-shares figure.
Fetch a deposit or a withdrawal
GET /deposits/{id} and GET /withdrawals/{id} · scope transaction:read
Each returns the object in its current state, including a terminal failure, which is reported as the object rather than as an error. A settled object answers with the same body the original POST returned. It is the poll surface the compensable errors point at, and the fetch half of the thin webhook payloads.
curl https://api.threecommabank.com/api/partner/v1/deposits/2f6c9a41-3e58-4b70-9d12-6a8f0c3e5b71 \
-H "Authorization: Bearer $TCB_API_KEY"An id that is not yours, or that belongs to a different flow, answers 404 INTENT_NOT_FOUND.
List your deposits and withdrawals
GET /deposits and GET /withdrawals · scope transaction:read
Both return your vault deposits or withdrawals, newest first, one page at a time.
| Query | Required | Notes |
|---|---|---|
status | no | Only movements in this status. One of the statuses below. |
cursor | no | The nextCursor from the previous page. Omit it for the first page. Treat it as opaque. |
limit | no | Rows per page, 1-100. Defaults to 25. |
curl -G https://api.threecommabank.com/api/partner/v1/withdrawals \
-H "Authorization: Bearer $TCB_API_KEY" \
--data-urlencode "limit=25"{
"success": true,
"data": {
"items": [
{
"withdrawalId": "7a1d0c55-91e2-4f36-8b0a-2e64c9d8f173",
"status": "COMPLETED",
"subAccountId": "5d3b8e10-2f47-4c9a-b6e8-0a71d4c2f395",
"currency": "USD",
"requestedAmount": "250",
"creditedAmount": "249.99",
"shares": "249.380000000000000000000000",
"estimatedAvailableAt": null,
"createdAt": "2026-10-06T10:02:05.000Z",
"settledAt": "2026-10-06T10:02:13.000Z"
}
],
"nextCursor": null
},
"timestamp": "2026-10-06T10:05:00.000Z"
}A deposit row carries depositId, status, subAccountId, currency, requestedAmount, shares, createdAt and settledAt. A withdrawal row adds:
| Field | Meaning |
|---|---|
requestedAmount | The amount you asked to withdraw. |
creditedAmount | What was credited to you, rounded down to the currency's precision. null until the withdrawal is COMPLETED. |
estimatedAvailableAt | When a queued withdrawal is expected to be available. It is set only for a queued withdrawal. Vault withdrawals on this API execute immediately or are refused, so it is null. Read settledAt for when a withdrawal was credited. |
shares is the vault shares the movement received or redeemed, at the vault's own share scale. On a deposit it is null unless the deposit is COMPLETED. A deposit's own response and GET /deposits/{id} carry the same figure as shares.minted. Reading it needs the vault's share scale, so on rare occasions these reads answer 503 CHAIN_UNAVAILABLE; retry. On a withdrawal it is null until the vault leg is done.
A malformed cursor is refused with 400 INVALID_AMOUNT. A well-formed cursor that matches none of your rows returns an empty page.
Statuses
Every list row uses the one savings status set that TCB uses on every surface. It is a summary of the state ladder above.
| Status | Meaning |
|---|---|
QUEUED | Accepted. Nothing irreversible has happened yet. |
PROCESSING | In flight. |
CREDIT_PENDING | The vault leg is done and money is owed to you that has not landed yet: a withdrawal's proceeds, or a deposit's refund. TCB is resolving it. Do not retry. |
COMPLETED | Done. |
FAILED | Ended with nothing moved. |
REFUNDED | Money moved and was returned to you. |
CANCELLED | Withdrawn before it executed. |
COMPLETED, FAILED, REFUNDED and CANCELLED are final.
Money caps
TCB may set ceilings on your account's money movement, per currency and per flow — deposits and withdrawals are capped independently, and conversions carry their own. Four kinds of ceiling exist, every one inclusive:
| Ceiling | Bounds |
|---|---|
maxTicket | A single request. |
daily | The flow's total over the current UTC calendar day. Resets at 00:00:00Z. |
monthly | The same, over the UTC calendar month. |
maxOutstanding | Deposits only: your net contributed amount — deposits in, minus withdrawals settled back out. Falls as withdrawals settle. |
No ceiling applies unless TCB has configured one for your account; TCB tells
you your ceilings when they are set, and there is no API to read or change
them. A request that would cross one is refused before anything moves with
400 DEPOSIT_CAP_EXCEEDED or 400 WITHDRAWAL_CAP_EXCEEDED: details.cap
names the ceiling, details.limit its value, and details.attempted what the
figure would have become. The idempotency key stays usable — resubmit the same
key when the window rolls over, after withdrawals settle, or with a smaller
amount.
The daily and monthly figures count your requests as soon as they are accepted, including requests still in flight; only a request that terminally failed with nothing moved stops counting.
Handle the errors
Nothing has been charged or withdrawn on any 4xx, or on a 503.
| Status | Code | What to do |
|---|---|---|
| 400 | IDEMPOTENCY_KEY_REQUIRED / IDEMPOTENCY_KEY_INVALID | Send a key, in one place, within the allowed characters and length. |
| 400 | INVALID_AMOUNT | amount was not a positive decimal string, or was below 0.000001. |
| 400 | AMOUNT_PRECISION_EXCEEDED | Round to details.allowedDecimals and resubmit. |
| 400 | INSUFFICIENT_BALANCE | The source fiat account does not hold the amount (deposit). |
| 400 | DEPOSIT_CAP_EXCEEDED / WITHDRAWAL_CAP_EXCEEDED | The request would cross a money cap set for your account. details.cap names the ceiling. Resubmit the same key when it clears, or send less. |
| 400 or 403 | SOURCE_ACCOUNT_NOT_USABLE / DESTINATION_ACCOUNT_NOT_USABLE | 400 when the named account is not a fiat account in that currency — including the account your crypto deposit address credits, which details.accountId names: convert it first; 403 when it is not active. Name another account. |
| 400 | INSUFFICIENT_VAULT_POSITION | Your wallet does not hold enough to redeem that amount. |
| 400 | INSUFFICIENT_SUB_ACCOUNT_POSITION | The named sub-account's available does not cover it. You may hold plenty elsewhere — name that sub-account, or withdraw less. |
| 403 | WALLET_NOT_ALLOWLISTED | Your wallet is not yet cleared for this vault. Contact TCB. |
| 403 | INSUFFICIENT_SCOPE | The key lacks vault:deposit, vault:withdraw or transaction:read. |
| 404 | VAULT_NOT_AVAILABLE | No active vault for that currency. |
| 404 | SOURCE_ACCOUNT_NOT_FOUND / DESTINATION_ACCOUNT_NOT_FOUND | The named account is not one of yours. |
| 404 | SUB_ACCOUNT_NOT_FOUND | The subAccountId is not one of yours. |
| 404 | INTENT_NOT_FOUND | No deposit or withdrawal with that id in your scope. |
| 409 | SOURCE_ACCOUNT_AMBIGUOUS / DESTINATION_ACCOUNT_AMBIGUOUS | Name the account explicitly. See naming the right account. |
| 409 | IDEMPOTENCY_KEY_REUSED | Same key, different parameters. Use a new key. |
| 409 | IDEMPOTENT_REQUEST_IN_PROGRESS | The first call under this key is still running. Retry the same key. |
| 409 | CURRENCY_NOT_CONFIGURED | That currency is not configured for this rail. Contact TCB. |
| 409 | VAULT_SUBSCRIPTIONS_CLOSED | The vault is not accepting deposits right now. |
| 409 | TOKEN_ISSUANCE_PAUSED | TCB has paused issuance of this token, so no new deposit is minted. Nothing was charged. details.idempotencyKeySpent is false when it was refused up front (the same key stays usable) and true when the pause landed mid-execution (that deposit is terminal FAILED; the next attempt needs a new key). Submitting again while the pause stands is refused again — contact TCB rather than retrying in a loop. Withdrawals are unaffected. |
| 409 | INSUFFICIENT_VAULT_LIQUIDITY | Retry at or below the details.covered figure. |
| 409 | WITHDRAWAL_AUTHORIZATION_CONFLICT | Two withdrawals raced for the same sub-account. Nothing moved — retry with the same key. |
| 409 | PARTNER_RAIL_FROZEN | Deposits and withdrawals are suspended for your account after an unexplained difference between your recorded position and the position TCB holds. Nothing was charged. A person lifts it; contact TCB. |
| 500 | DEPOSIT_FAILED | Nothing was charged. Safe to retry under a new key. |
| 500 | DEPOSIT_FAILED_PENDING_COMPENSATION / WITHDRAWAL_FAILED_PENDING_COMPENSATION | Money moved. Do not re-key — see the callout above. |
| 500 | DEPOSIT_SETTLEMENT_NOT_RECORDED / WITHDRAWAL_SETTLEMENT_NOT_RECORDED | It completed and only the record of it failed. Nothing is owed and nothing needs unwinding. Do not re-key. |
| 500 | LEDGER_ENTRY_NOT_RECORDED | The money completed but the exact share movement could not be read back, so no ledger entry was posted. Do not re-key; TCB retries the read and posts the entry, or an operator resolves it. |
| 503 | CHAIN_UNAVAILABLE / GAS_PREFLIGHT_FAILED | Nothing was charged or withdrawn. Retry. |
| 503 | PARTNER_WALLET_PROVISIONING_FAILED | Your first deposit could not create your custody wallet just now. Nothing was charged and your idempotency key was not claimed: resend with the same key or a new one, with the same or changed parameters. |
The full catalogue is in Errors.
Receive the events
Subscribe at POST /webhooks to deposit.settled, deposit.failed, withdrawal.settled and withdrawal.failed. A compensable outcome fires no event — 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
Settlements
Pay another client of the bank instantly on CommaNet — choose the handle you are paid by, confirm who you are paying, and settle.
Tokenised deposits, step by step
Run the vault deposit and withdrawal one step at a time — fiat to tcbUSD, tcbUSD into the vault and back out, through the vault's queue when it cannot pay at once.