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

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.

StatusWhat it meansWhat to do
PENDINGAccepted, nothing irreversible has happenedWait or poll.
EXECUTING_ONCHAINThe legs are in flightWait or poll.
SETTLEDTerminal. Deposit: your shares are minted. Withdrawal: your fiat is credited.Done.
FAILEDTerminal. Nothing was charged and nothing moved.Safe to retry under a new key.
FAILED_PENDING_COMPENSATIONMoney moved and the unwind is not confirmedTCB resolves it — automatically where the outcome can be proven, by an operator otherwise. Do not retry.
COMPENSATEDTerminal. 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.

FieldTypeRequiredNotes
currencystring, 3 lettersyesSelects the active vault for that currency.
amountdecimal stringyesNever a JSON number. No more fractional digits than the currency settles at — USD settles at 2. Amounts below 0.000001 are refused.
sourceAccountIdUUIDnoThe 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.
subAccountIdUUIDnoThe 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.
idempotencyKeystring, up to 100 charsnoAlternative 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.

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

  • amount is normalised, not echoed. A submitted "1000.00" comes back as "1000".
  • shares.balance is the sub-account's whole share balance after this deposit, as a decimal string at the vault's own share scale. It is null when 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.mode is TCB_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.

FieldTypeRequiredNotes
currencystring, 3 lettersyesSelects the vault and the settlement currency.
amountdecimal stringyesNo 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.
destinationAccountIdUUIDnoThe 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.
subAccountIdUUIDnoThe vault sub-account to withdraw from. Omitted, your default sub-account is used.
idempotencyKeystring, up to 100 charsnoAlternative to the Idempotency-Key header.

What you can withdraw

A withdrawal is authorised against the named sub-account alone:

available = settled − reserved
  • settled — 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.

Request
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"
  }'
Response 201
{
  "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.

FieldMeaning
amountWhat you requested, normalised.
settledAmountWhat 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.
precisionResidueWhat 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.

Request
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.

QueryRequiredNotes
statusnoOnly movements in this status. One of the statuses below.
cursornoThe nextCursor from the previous page. Omit it for the first page. Treat it as opaque.
limitnoRows per page, 1-100. Defaults to 25.
Request
curl -G https://api.threecommabank.com/api/partner/v1/withdrawals \
  -H "Authorization: Bearer $TCB_API_KEY" \
  --data-urlencode "limit=25"
Response 200
{
  "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:

FieldMeaning
requestedAmountThe amount you asked to withdraw.
creditedAmountWhat was credited to you, rounded down to the currency's precision. null until the withdrawal is COMPLETED.
estimatedAvailableAtWhen 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.

StatusMeaning
QUEUEDAccepted. Nothing irreversible has happened yet.
PROCESSINGIn flight.
CREDIT_PENDINGThe 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.
COMPLETEDDone.
FAILEDEnded with nothing moved.
REFUNDEDMoney moved and was returned to you.
CANCELLEDWithdrawn 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:

CeilingBounds
maxTicketA single request.
dailyThe flow's total over the current UTC calendar day. Resets at 00:00:00Z.
monthlyThe same, over the UTC calendar month.
maxOutstandingDeposits 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.

StatusCodeWhat to do
400IDEMPOTENCY_KEY_REQUIRED / IDEMPOTENCY_KEY_INVALIDSend a key, in one place, within the allowed characters and length.
400INVALID_AMOUNTamount was not a positive decimal string, or was below 0.000001.
400AMOUNT_PRECISION_EXCEEDEDRound to details.allowedDecimals and resubmit.
400INSUFFICIENT_BALANCEThe source fiat account does not hold the amount (deposit).
400DEPOSIT_CAP_EXCEEDED / WITHDRAWAL_CAP_EXCEEDEDThe 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 403SOURCE_ACCOUNT_NOT_USABLE / DESTINATION_ACCOUNT_NOT_USABLE400 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.
400INSUFFICIENT_VAULT_POSITIONYour wallet does not hold enough to redeem that amount.
400INSUFFICIENT_SUB_ACCOUNT_POSITIONThe named sub-account's available does not cover it. You may hold plenty elsewhere — name that sub-account, or withdraw less.
403WALLET_NOT_ALLOWLISTEDYour wallet is not yet cleared for this vault. Contact TCB.
403INSUFFICIENT_SCOPEThe key lacks vault:deposit, vault:withdraw or transaction:read.
404VAULT_NOT_AVAILABLENo active vault for that currency.
404SOURCE_ACCOUNT_NOT_FOUND / DESTINATION_ACCOUNT_NOT_FOUNDThe named account is not one of yours.
404SUB_ACCOUNT_NOT_FOUNDThe subAccountId is not one of yours.
404INTENT_NOT_FOUNDNo deposit or withdrawal with that id in your scope.
409SOURCE_ACCOUNT_AMBIGUOUS / DESTINATION_ACCOUNT_AMBIGUOUSName the account explicitly. See naming the right account.
409IDEMPOTENCY_KEY_REUSEDSame key, different parameters. Use a new key.
409IDEMPOTENT_REQUEST_IN_PROGRESSThe first call under this key is still running. Retry the same key.
409CURRENCY_NOT_CONFIGUREDThat currency is not configured for this rail. Contact TCB.
409VAULT_SUBSCRIPTIONS_CLOSEDThe vault is not accepting deposits right now.
409TOKEN_ISSUANCE_PAUSEDTCB 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.
409INSUFFICIENT_VAULT_LIQUIDITYRetry at or below the details.covered figure.
409WITHDRAWAL_AUTHORIZATION_CONFLICTTwo withdrawals raced for the same sub-account. Nothing moved — retry with the same key.
409PARTNER_RAIL_FROZENDeposits 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.
500DEPOSIT_FAILEDNothing was charged. Safe to retry under a new key.
500DEPOSIT_FAILED_PENDING_COMPENSATION / WITHDRAWAL_FAILED_PENDING_COMPENSATIONMoney moved. Do not re-key — see the callout above.
500DEPOSIT_SETTLEMENT_NOT_RECORDED / WITHDRAWAL_SETTLEMENT_NOT_RECORDEDIt completed and only the record of it failed. Nothing is owed and nothing needs unwinding. Do not re-key.
500LEDGER_ENTRY_NOT_RECORDEDThe 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.
503CHAIN_UNAVAILABLE / GAS_PREFLIGHT_FAILEDNothing was charged or withdrawn. Retry.
503PARTNER_WALLET_PROVISIONING_FAILEDYour 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

On this page