Crypto deposits
Receive crypto at a TCB-issued deposit address, hold it as a credited balance, and move it onward.
Concepts: Accounts · Webhooks · Roadmap
A crypto deposit is initiated on-chain, not by an API call: the transfer itself is the request, and its transaction hash is its identity. This guide covers getting your deposit address, reading arrivals, where credited funds rest, and how to move them onward.
TCB issues your integration one deposit address per asset and chain. A confirmed on-chain transfer to it is credited to an account you control, carries the same permanent, replay-safe identity as every other money movement, and is announced by webhook rather than discovered by polling. The credited balance is a safe resting state: nothing moves it onward until you say so.
The pilot accepts USDC on Arbitrum Sepolia and nothing else — see what arrives that the pilot does not accept for what happens to anything else sent to your address.
Get your deposit address
POST /crypto-deposit-addresses · scope account:manage · idempotency key required
Issues your deposit address for one asset on one chain. The first call also creates the account arrivals are credited to; every later call for the same pair returns the same address, whatever idempotency key it carries — the address is static, so you can print it once into your own configuration.
| Field | Type | Required | Notes |
|---|---|---|---|
asset | string | yes | USDC is the only asset the pilot accepts. |
chain | string | yes | ARBITRUM is the only chain the pilot accepts. |
idempotencyKey | string, up to 100 chars | no | Alternative to the Idempotency-Key header. |
curl -X POST https://api.threecommabank.com/api/partner/v1/crypto-deposit-addresses \
-H "Authorization: Bearer $TCB_API_KEY" \
-H "Idempotency-Key: acme-crypto-address-0001" \
-H "Content-Type: application/json" \
-d '{ "asset": "USDC", "chain": "ARBITRUM" }'{
"success": true,
"data": {
"addressId": "9d2c6a4e-73b8-4e8b-a35c-4692b0f11d70",
"address": "0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B",
"chain": "ARBITRUM",
"asset": "USDC",
"accountId": "7e2a0c53-4d81-49b6-8f37-1a5c9e0b2d74",
"createdAt": "2026-09-03T09:12:04.000Z",
"disclosure": { "version": "2026-09-06", "summary": "…", "items": ["…"] }
},
"timestamp": "2026-09-03T09:12:04.318Z"
}Every response on this page carries the same disclosure object printed at the top of this page. It is abbreviated in the examples.
addressis shown in checksummed form. It is not case-sensitive: transfers to any casing of it are recognised.accountIdis the account arrivals are credited to. It appears onGET /accounts/{id}with its balance — see where credited funds rest.- Naming an asset or chain outside the pilot answers
409 ASSET_NOT_SUPPORTEDor409 CHAIN_NOT_SUPPORTED. These are scope facts, not transient errors — there is no point retrying them.
Follow the statuses
A crypto deposit walks at most four states. GET /crypto-deposits/{id} reports
the current one. Every arrow below is coloured by where it lands: blue for in
progress, green for credited, red for an arrival that is recorded and never
credited.
| Status | What it means | What to do |
|---|---|---|
DETECTED | The arrival is recorded, the credit not yet applied | Transient and usually invisible — arrivals are reported to TCB already confirmed, so a deposit normally first appears as CREDITED. |
CONFIRMING | Recorded, waiting for confirmations | Transient and usually invisible, for the same reason. |
CREDITED | Terminal. The funds are on your account balance. | Done. The crypto_deposit.credited webhook has fired. |
FAILED | Terminal. The arrival was recorded but not credited. | The funds were received but are not on your balance — see below. Contact TCB. |
There is no failure state in which an arrival disappears: everything that reaches your address is either credited or recorded as FAILED.
Read arrivals
GET /crypto-deposits · scope transaction:read
Every recorded arrival at your deposit addresses, newest first, CREDITED and FAILED alike. Pages with cursor and limit (1–100, default 25) like every other list.
GET /crypto-deposits/{id} · scope transaction:read
One arrival in its current state. This is the fetch half of the thin crypto_deposit.credited webhook payload — the event carries only a resource pointer, and this route answers it.
curl https://api.threecommabank.com/api/partner/v1/crypto-deposits/2b7e1c6a-2d59-46a4-8f38-7c1542be9038 \
-H "Authorization: Bearer $TCB_API_KEY"{
"success": true,
"data": {
"cryptoDepositId": "2b7e1c6a-2d59-46a4-8f38-7c1542be9038",
"status": "CREDITED",
"asset": "USDC",
"chain": "ARBITRUM",
"amount": "1000",
"txHash": "0x4a1f9c07d3b28e615a0c47f8d29b3e6c15087af2d94b6c308e17b5d02fa4c396",
"transferIndex": null,
"creditedAccountId": "7e2a0c53-4d81-49b6-8f37-1a5c9e0b2d74",
"detectedAt": "2026-09-03T10:15:31.000Z",
"creditedAt": "2026-09-03T10:15:31.000Z",
"disclosure": { "version": "2026-09-06", "summary": "…", "items": ["…"] }
},
"timestamp": "2026-09-03T10:15:34.271Z"
}Three fields carry the identity model:
txHashis the deposit's identity. A replayed report of the same transfer never produces a second deposit or a second credit, and thecryptoDepositIdstays the same across replays.transferIndexdistinguishes several transfers arriving in one transaction — a batch contract paying you twice in one call is two deposits, each credited, each with its own id and its own webhook event.amountis a decimal string at the asset's own scale, like every amount on this API.
An id that is not yours answers 404 CRYPTO_DEPOSIT_NOT_FOUND.
What arrives that the pilot does not accept
An address cannot choose what is sent to it. If something outside the pilot arrives at your deposit address — a different token, the chain's native gas asset, a token TCB cannot identify — it is recorded and never credited: the arrival appears on GET /crypto-deposits as FAILED, nothing is added to any balance, and no webhook event fires. It is not silently dropped, and it is not returned automatically; resolving it is a manual step on TCB's side, so contact TCB quoting the cryptoDepositId.
Send only the asset the address was issued for.
Where credited funds rest
A credited arrival rests as balance on accountId, the account issued with your address. Three reads show it: GET /accounts/{id} reports the balance, GET /accounts lists it alongside your fiat accounts, and GET /crypto-deposits lists each arrival that built it. Nothing moves the balance on its own: there is no automatic sweep, and an arrival never enters a vault position or converts by itself.
Move credited funds onward
A credited arrival rests as balance on accountId until you move it. Two calls take it from there, each with its own idempotency key and its own webhook events, and neither runs on its own:
POST /conversions— sell the credited USDC into a fiat currency you hold an account in. Name the account arrivals are credited to assourceAccountId; omit it and that account is used when it is the only active USDC account you hold. Hold a second USDC account and the call is refused as409 CONVERSION_ACCOUNT_AMBIGUOUSwithdetails.accountIdsnaming both — TCB never picks between them for you.POST /deposits— put the fiat the conversion credited into a curated vault position, from the fiat account it landed in. Money caps apply to each call on its own terms.
The account arrivals are credited to holds crypto, and a vault takes fiat: naming it as sourceAccountId on POST /deposits answers 400 SOURCE_ACCOUNT_NOT_USABLE, and naming it as destinationAccountId on POST /withdrawals answers 400 DESTINATION_ACCOUNT_NOT_USABLE. Nothing moves on either.
Only your own key reaches the account. Another client naming its id, on any call, gets the same 404 an unknown id gets.
curl -X POST https://api.threecommabank.com/api/partner/v1/conversions \
-H "Authorization: Bearer $TCB_API_KEY" \
-H "Idempotency-Key: acme-usdc-convert-0001" \
-H "Content-Type: application/json" \
-d '{ "fromCurrency": "USDC", "toCurrency": "USD", "amount": "1000.000000",
"sourceAccountId": "7e2a0c53-4d81-49b6-8f37-1a5c9e0b2d74" }'curl -X POST https://api.threecommabank.com/api/partner/v1/deposits \
-H "Authorization: Bearer $TCB_API_KEY" \
-H "Idempotency-Key: acme-usd-deposit-0001" \
-H "Content-Type: application/json" \
-d '{ "currency": "USD", "amount": "999.80" }'The conversion's to.amount is what the fiat account actually received, at the fiat currency's own precision, so it is safe to deposit as-is. Read rate.pricingModel on the conversion response before you report the rate anywhere: in the pilot it is SIMULATED.
Handle the errors
| Status | Code | What to do |
|---|---|---|
| 400 | IDEMPOTENCY_KEY_REQUIRED / IDEMPOTENCY_KEY_INVALID | Send a key, in one place, within the allowed characters and length. |
| 403 | INSUFFICIENT_SCOPE | The key lacks account:manage (address) or transaction:read (reads). |
| 404 | CRYPTO_DEPOSIT_NOT_FOUND | No crypto deposit with that id in your scope. |
| 409 | ASSET_NOT_SUPPORTED | The pilot issues addresses for USDC only. Not retryable. |
| 409 | CHAIN_NOT_SUPPORTED | The pilot issues addresses on Arbitrum Sepolia only. Not retryable. |
| 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. |
The full catalogue is in Errors.
Receive the events
Subscribe at POST /webhooks to crypto_deposit.credited. It fires exactly once per credit, when the credit is applied, with a thin payload pointing at GET /crypto-deposits/{id}.
A credit on one of your sub-clients' crypto networks fires the same event to your endpoint. The event names the sub-client, and the fetch needs x-on-behalf-of set to it, like any other sub-client resource.
A FAILED record fires no event, deliberately: it is not a state your integration should automate against — the resolution is manual, and the record is on the list read whenever you want it.
The machine-readable specification for these endpoints is openapi.partner.v1.json. Where it and this page disagree, the specification is authoritative.