Destinations
Register the third parties a payout can name, and follow each destination through screening, approval and its holding period.
Concepts: Roadmap
A payout to a third party is a two-step contract, and this page is the first step. You register a destination — a bank account or a wallet — and it must clear screening, TCB approval, and a holding period before anything can ever be sent to it. Registering a destination and paying it are separate acts with separate scopes, because the register step is where every control lives.
The second step is Payouts. It only ever names a destination that has completed the lifecycle on this page: no call pays a raw, unregistered destination. Register your destinations ahead of needing them, so your destination book is screened, approved and out of its holding period before you need to pay one.
Every route on this page acts as you, never as a sub-client: they refuse x-on-behalf-of with 400 ON_BEHALF_OF_NOT_ALLOWED_HERE. A destination belongs to your own registry.
A destination is never usable at registration
Every destination you register starts in PENDING_APPROVAL and becomes usable only after TCB approves it and its holding period has elapsed. You have no way to shorten or skip either: an API-registered destination always requires TCB approval, and only a TCB operator can release a hold early. Build your integration to register destinations ahead of needing them, and to treat usable: false as the normal state of a fresh one.
Follow the lifecycle
GET /beneficiaries/{id} reports where a destination stands. Every arrow below
is coloured by where it lands: blue for in progress, amber for held, green for
usable, red for blocked.
| Status | What it means | What to do |
|---|---|---|
PENDING_APPROVAL | Screened and awaiting TCB approval. Every destination you register starts here. | Wait. Subscribe to beneficiary.activated, or poll. |
HOLD | Approved. The holding period runs until usableAt. | Wait for usableAt. |
BLOCKED | Screening matched it, or a TCB operator blocked it. Nothing can ever be sent to it while blocked. | Contact TCB if you believe it is wrong. |
DELETED | You removed it. The record stays readable. | Register a new destination if you still need one. |
usable is never a stored state — it is derived, and true exactly when the status is HOLD and usableAt has passed. The holding period's clock starts at approval, not at registration, and runs for 24 hours by default (never less than four). The specification's status vocabulary carries two further values, PENDING_VERIFICATION and SUPERSEDED, which registrations made through the API do not produce.
Screening runs at registration: a bank destination is screened as the named party, a wallet as the address itself. A sanctions match lands the destination in BLOCKED immediately — the registration still answers 201, with the status telling you what happened. An open-list match does not block; it rides out on warnings[] and the destination continues through the lifecycle. The payout call checks the destination again at initiation and once more immediately before money leaves, so a destination blocked after activation is refused at send time too.
Register a destination
POST /beneficiaries · scope payee:manage · idempotency key required
One call registers the payee and the destination itself. The instrument object is validated server-side against the rail registry for the stated currency — or, for a wallet, the chain — so a malformed IBAN, sort code or address is refused with every field problem listed, before anything is saved.
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | The payee's legal or account-holder name. For a bank destination this is the name screening runs against. |
entityType | INDIVIDUAL or BUSINESS | no | Defaults to INDIVIDUAL. |
country | string, 2 letters | no | The payee's country. It helps the server infer the right rail. |
instrument.kind | FIAT_BANK or CRYPTO_ADDRESS | yes | A bank account, or a wallet. |
instrument.currency | string | for FIAT_BANK | ISO 4217. |
instrument.rail | string | no | Optional override — the rail is inferred from country and currency by default. |
instrument.chain | string | for CRYPTO_ADDRESS | The network the address is paid on. Required for a single-network address (Bitcoin, Solana). For an EVM address give chain or networks; when you give both, chain is the primary network and must be one of networks. |
instrument.networks | array of strings | no | EVM addresses only: every network the destination is payable on from the start, up to 10. Each must be one TCB sends on (see Pay one address on several networks). |
instrument.details | object | yes | The destination's identifying fields: iban / accountNumber / bicSwift / sortCode / routingNumber / bankCountry for a bank account, address (and optional memo) for a wallet. |
instrument.label | string | no | Your free-text label for this destination. |
instrument.travelRule | object | for CRYPTO_ADDRESS | Whether the wallet is self-hosted (selfHosted), and the institution holding it (vaspName) when it is not. A wallet registration without this capture is refused. |
nickname | string | no | Your display alias for the payee. |
idempotencyKey | string, up to 100 chars | no | Alternative to the Idempotency-Key header. |
Registering details you already hold live anywhere in your registry is refused with 409 DUPLICATE_INSTRUMENT — the response's existingBeneficiaryId names the destination you already have. That check runs before the registration is saved, so two registrations of the same details racing each other under different idempotency keys can both succeed; the later one then carries a DUPLICATE_IN_ORG warning naming the other. Keep one and remove the other. A near-identical destination (one digit off an existing account, a lookalike wallet address) is allowed but flagged on warnings[]: check it digit by digit before relying on it.
curl -X POST https://api.threecommabank.com/api/partner/v1/beneficiaries \
-H "Authorization: Bearer $TCB_API_KEY" \
-H "Idempotency-Key: acme-payee-2026-09-03-0001" \
-H "Content-Type: application/json" \
-d '{
"name": "Acme Supplies GmbH",
"entityType": "BUSINESS",
"country": "DE",
"instrument": {
"kind": "FIAT_BANK",
"currency": "EUR",
"details": {
"iban": "DE89370400440532013000",
"bicSwift": "COBADEFFXXX"
},
"label": "Acme main"
}
}'{
"success": true,
"data": {
"beneficiaryId": "9b3e5c17-2a80-4f64-b1d9-6c0e8a4f2d53",
"instrumentId": "1d7a9f42-6b05-4e83-a2c1-8f3b0d6e4c97",
"name": "Acme Supplies GmbH",
"nickname": null,
"kind": "FIAT_BANK",
"currency": "EUR",
"rail": "SEPA",
"chain": null,
"label": "Acme main",
"details": {
"iban": "DE89370400440532013000",
"bicSwift": "COBADEFFXXX"
},
"last4": "3000",
"status": "PENDING_APPROVAL",
"usable": false,
"usableAt": null,
"screening": { "status": "CLEAR" },
"addressFamily": null,
"networks": [],
"reachableChains": [],
"ownership": { "proven": false, "at": null },
"extensible": false,
"extensibility": null,
"warnings": [],
"createdAt": "2026-09-03T10:15:30.000Z",
"deletedAt": null,
"disclosure": { "version": "2026-09-06", "summary": "…", "items": ["…"] }
},
"timestamp": "2026-09-03T10:15:30.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:
statusisPENDING_APPROVALandusableisfalse. That is the normal, expected answer — see the callout above.usableAtstaysnulluntil TCB approval starts the holding clock.screening.statusis where screening stands.CLEARpassed;REVIEWis usable-when-activated but flagged (a matching warning explains);HITmeans the destination came backBLOCKED;UNSCREENEDmeans the checker was unavailable at registration — the destination still requires approval, and screening runs again before anything is ever sent.warnings[]carries the non-blocking signals:LOOKALIKE(very close to a destination you already hold),SCREENING_REVIEW(an open-list match) andDUPLICATE_IN_ORG(the same details landed under another of your payees at the same moment — see above). Surface them to whoever approves payees on your side.
Register a wallet
A CRYPTO_ADDRESS destination takes the same call with chain, an address in details, and the travelRule capture — which is mandatory, not decorative: a wallet registration without it is refused outright.
{
"kind": "CRYPTO_ADDRESS",
"chain": "ARBITRUM",
"details": { "address": "0x8Ba1f109551bD432803012645Ac136ddd64DBA72" },
"label": "Treasury wallet",
"travelRule": { "selfHosted": false, "vaspName": "Example Exchange Ltd" }
}Set selfHosted to true for a wallet you or the payee control directly; when it is false, vaspName must name the institution holding it. Wallets are screened as addresses, and screening fails closed for them: a wallet is never paid while the screening check cannot run.
Pay one address on several networks
An EVM address is the same address on Ethereum, Polygon, Avalanche, Arbitrum, Optimism and Base. A wallet destination for an EVM address is one destination with a list of networks, not one destination per network. Screening, TCB approval, the holding period and the Travel Rule capture attach to the address, so they are served once.
Every wallet destination carries these fields:
| Field | What it tells you |
|---|---|
addressFamily | EVM when the address format is shared by several networks. null for a bank destination and for a single-network wallet (Bitcoin, Solana). |
networks[] | Every network the destination has been opened on, oldest first: id, chain, status, screening ({ result, at }, or null until the address is screened on that network) and addedAt. |
networks[].status | ACTIVE — a payment may use this network once the destination is usable. PENDING_APPROVAL — you added it and TCB has not yet approved it. RETIRED — removed, kept for the record. |
chain | The primary ACTIVE network. |
reachableChains | The networks TCB sends on for this address. The networks you can register or add. |
ownership | { proven, at }: whether a signed ownership proof for the address is on file. |
extensible | true when networks can be added: a self-hosted wallet, ownership proven, no contract code on any of its active networks. |
extensibility | { extensible, reason }: the same answer as extensible and, when it is false, why: OWNERSHIP_NOT_PROVEN (submit an ownership proof), EXCHANGE_OR_VASP_HOSTED (an exchange address is per network: register it again for another one), CONTRACT_CODE_ON_NETWORK (the address carries contract code on an active network) or NO_ADDRESS_FAMILY (the address lives on one network). reason is null when networks can be added. null for a bank destination. |
Classify an address first
GET /beneficiaries/address-info?address=… · scope payee:manage
Before registering, ask what an address is. Nothing is saved.
curl "https://api.threecommabank.com/api/partner/v1/beneficiaries/address-info?address=0x8ba1f109551bd432803012645ac136ddd64dba72" \
-H "Authorization: Bearer $TCB_API_KEY"{
"success": true,
"data": {
"family": "EVM",
"validChains": ["ETHEREUM", "POLYGON", "AVALANCHE", "ARBITRUM", "OPTIMISM", "BASE"],
"reachableChains": ["ETHEREUM", "POLYGON", "ARBITRUM"],
"contractOn": [],
"unknownOn": [],
"checksumAddress": "0x8Ba1f109551bD432803012645Ac136ddd64DBA72"
},
"timestamp": "2026-10-21T10:15:30.271Z"
}reachableChains is what instrument.networks may name. A network in contractOn means the address is a contract wallet there (a Safe, a smart account): such a wallet has different owners on each network, so register it on one network at a time. unknownOn lists networks where the check could not run; they are treated the same way. The optional memo parameter is accepted for networks that carry a memo or tag.
Register on several networks
Name the networks in instrument.networks at registration. Several networks need a self-hosted wallet (travelRule.selfHosted: true) with no contract code on any of them; an exchange-hosted address is registered one network at a time, because an exchange's deposit address is per network.
{
"kind": "CRYPTO_ADDRESS",
"networks": ["ETHEREUM", "POLYGON", "ARBITRUM"],
"details": { "address": "0x8Ba1f109551bD432803012645Ac136ddd64DBA72" },
"travelRule": { "selfHosted": true }
}The destination comes back with one networks[] entry per network, each ACTIVE, and the destination itself in PENDING_APPROVAL like any other. The same address registered again under any network of its family is refused with 409 DUPLICATE_INSTRUMENT.
Prove ownership
GET /beneficiaries/{beneficiaryId}/instruments/{iid}/ownership-proof/challenge and POST /beneficiaries/{beneficiaryId}/instruments/{iid}/ownership-proof · scope payee:manage
To add networks later, the wallet's key signs a challenge. iid is the destination's instrumentId.
- Fetch the challenge. It returns
message,nonceandexpiresAt; the challenge is valid for 15 minutes and only for this destination. - Have the wallet sign
messageunchanged withpersonal_sign(EIP-191). - Submit
{ "message": "…", "signature": "0x…" }. The response is the destination, withownership.proven: trueandextensiblerecomputed.
A signature by any other key, an edited message, or an expired challenge is refused with 400 OWNERSHIP_PROOF_INVALID. Fetch a new challenge and sign again.
Add a network
POST /beneficiaries/{beneficiaryId}/instruments/{iid}/networks · scope payee:manage
{ "chain": "BASE" }The destination must be extensible and the network must be in its reachableChains. The network lands PENDING_APPROVAL and becomes ACTIVE once TCB approves it. The destination keeps its status and its usableAt: no new holding period starts. The response is the destination plus network: { chain, status }.
The address is screened on the new network when you add it. A refusal tells you why in code, and for NETWORK_NOT_EXTENSIBLE in details.reason (see the error table below).
Retire a network
DELETE /beneficiaries/{beneficiaryId}/instruments/{iid}/networks/{chain} · scope payee:manage
Stops payments on one network. The network stays in networks[] with status RETIRED, and you can add it again later. When you retire the primary network, chain moves to the oldest remaining ACTIVE one. The last ACTIVE network cannot be retired (409 LAST_NETWORK): remove the destination instead.
Watch it become usable
Nothing on your side advances the lifecycle — TCB reviews and approves each API-registered destination, the approval starts the holding clock, and the destination becomes usable when the clock runs out. Two ways to follow it:
- Subscribe to
beneficiary.activatedatPOST /webhooks. It fires when the destination became usable — approval granted and holding period elapsed — and points atGET /beneficiaries/{id}.beneficiary.blockedis its counterpart: screening or a TCB operator blocked the destination. - Poll
GET /beneficiaries/{id}and readusable. The read is authoritative: whatever events you have or have not received,usable: trueon a fetch means a payout may name this destination.
List and fetch destinations
GET /beneficiaries and GET /beneficiaries/{id} · scope payee:read
The list returns your live destinations, oldest first, cursor/limit paginated (up to 100 per page, 25 by default), and filterable with ?status= — ?status=HOLD for everything approved and serving its holding period, ?status=BLOCKED for what screening or an operator stopped. Removed destinations are not listed, but stay fetchable by id, with deletedAt set.
curl "https://api.threecommabank.com/api/partner/v1/beneficiaries?status=PENDING_APPROVAL" \
-H "Authorization: Bearer $TCB_API_KEY"An id that is not yours answers 404 BENEFICIARY_NOT_FOUND — a destination belonging to any other principal is indistinguishable from one that does not exist.
Remove a destination
DELETE /beneficiaries/{id} · scope payee:manage
Takes the destination out of use, whatever state it is in. The record is kept — the response and later fetches show it with deletedAt set and status DELETED — and nothing can ever be sent to it once removed. Removing an already-removed destination answers its current state, so a retry is safe. There is no way to edit a destination's identifying details: register the corrected destination as a new one (it serves its own approval and holding period) and remove the old.
Handle the errors
Nothing is saved on any 4xx, except NETWORK_NOT_ADDED: after it the destination's status may have changed, so fetch it again.
| Status | Code | What to do |
|---|---|---|
| 400 | (validation list) | The registry refused the destination — every field problem is listed in message. This includes a missing travelRule on a wallet, and an instrument.kind this registry does not accept. |
| 400 | IDEMPOTENCY_KEY_REQUIRED / IDEMPOTENCY_KEY_INVALID | Send a key, in one place, within the allowed characters and length. |
| 400 | INVALID_AMOUNT | A malformed limit or status query value on the list route. |
| 403 | INSUFFICIENT_SCOPE | The key lacks payee:manage or payee:read. |
| 404 | BENEFICIARY_NOT_FOUND | No destination with that id in your scope — also when iid is not an instrument of that destination. |
| 409 | DUPLICATE_INSTRUMENT | You already hold these details live — existingBeneficiaryId and existingInstrumentId name where. Use the existing destination, or remove it first if you meant to replace it. |
| 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. |
| 400 / 409 | NETWORK_NOT_REACHABLE | A network you named is not one TCB sends on for this address, or the address carries contract code there. Name a network from reachableChains; register a contract wallet one network at a time. |
| 400 / 409 | NETWORK_NOT_EXTENSIBLE | The destination cannot take this network. details.reason is NO_ADDRESS_FAMILY, CHAIN_OUTSIDE_FAMILY, EXCHANGE_OR_VASP_HOSTED, OWNERSHIP_NOT_PROVEN or CONTRACT_CODE_ON_NETWORK. |
| 400 | OWNERSHIP_PROOF_INVALID | Not a current challenge for this destination, or not signed by the address's key. Fetch a new challenge and sign it unchanged. |
| 409 | NETWORK_ALREADY_ACTIVE | Already payable on that network. |
| 409 | NETWORK_PENDING_APPROVAL | You already added that network; it awaits TCB approval. |
| 409 | NETWORK_NOT_ADDED | The network could not be added. Fetch the destination again: its status may have changed. |
| 409 | LAST_NETWORK | The destination's only ACTIVE network. Remove the destination instead. |
| 409 | NETWORK_ALREADY_RETIRED | That network is already retired. |
| 409 | DUPLICATE_INSTRUMENT (network add) | Another wallet of the same destination already pays this address on that network, or is waiting for approval on it. existingInstrumentId names it. Use that one. |
| 409 | INSTRUMENT_NOT_MUTABLE | The wallet is blocked, superseded or removed, so its networks and ownership proof cannot change. details.status says which. |
The full catalogue is in Errors.
Receive the events
Subscribe at POST /webhooks to beneficiary.activated and beneficiary.blocked. Payloads are thin — fetch the destination through GET /beneficiaries/{id} and act on the response, never on the event alone. The fetch is also your fallback: if a delivery goes astray, the usable field on a read is always the current truth.
The machine-readable specification for these endpoints is openapi.partner.v1.json. Where it and this page disagree, the specification is authoritative.