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

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.

FieldTypeRequiredNotes
assetstringyesUSDC is the only asset the pilot accepts.
chainstringyesARBITRUM is the only chain the pilot accepts.
idempotencyKeystring, up to 100 charsnoAlternative to the Idempotency-Key header.
Request
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" }'
Response 201
{
  "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.

  • address is shown in checksummed form. It is not case-sensitive: transfers to any casing of it are recognised.
  • accountId is the account arrivals are credited to. It appears on GET /accounts/{id} with its balance — see where credited funds rest.
  • Naming an asset or chain outside the pilot answers 409 ASSET_NOT_SUPPORTED or 409 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.

StatusWhat it meansWhat to do
DETECTEDThe arrival is recorded, the credit not yet appliedTransient and usually invisible — arrivals are reported to TCB already confirmed, so a deposit normally first appears as CREDITED.
CONFIRMINGRecorded, waiting for confirmationsTransient and usually invisible, for the same reason.
CREDITEDTerminal. The funds are on your account balance.Done. The crypto_deposit.credited webhook has fired.
FAILEDTerminal. 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.

Request
curl https://api.threecommabank.com/api/partner/v1/crypto-deposits/2b7e1c6a-2d59-46a4-8f38-7c1542be9038 \
  -H "Authorization: Bearer $TCB_API_KEY"
Response 200
{
  "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:

  • txHash is the deposit's identity. A replayed report of the same transfer never produces a second deposit or a second credit, and the cryptoDepositId stays the same across replays.
  • transferIndex distinguishes 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.
  • amount is 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:

  1. POST /conversions — sell the credited USDC into a fiat currency you hold an account in. Name the account arrivals are credited to as sourceAccountId; 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 as 409 CONVERSION_ACCOUNT_AMBIGUOUS with details.accountIds naming both — TCB never picks between them for you.
  2. 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.

1. Convert the credited USDC
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" }'
2. Deposit the fiat into the vault
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

StatusCodeWhat to do
400IDEMPOTENCY_KEY_REQUIRED / IDEMPOTENCY_KEY_INVALIDSend a key, in one place, within the allowed characters and length.
403INSUFFICIENT_SCOPEThe key lacks account:manage (address) or transaction:read (reads).
404CRYPTO_DEPOSIT_NOT_FOUNDNo crypto deposit with that id in your scope.
409ASSET_NOT_SUPPORTEDThe pilot issues addresses for USDC only. Not retryable.
409CHAIN_NOT_SUPPORTEDThe pilot issues addresses on Arbitrum Sepolia only. Not retryable.
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.

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.

Pilot disclosure

On this page