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

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.

StatusWhat it meansWhat to do
PENDING_APPROVALScreened and awaiting TCB approval. Every destination you register starts here.Wait. Subscribe to beneficiary.activated, or poll.
HOLDApproved. The holding period runs until usableAt.Wait for usableAt.
BLOCKEDScreening 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.
DELETEDYou 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.

FieldTypeRequiredNotes
namestringyesThe payee's legal or account-holder name. For a bank destination this is the name screening runs against.
entityTypeINDIVIDUAL or BUSINESSnoDefaults to INDIVIDUAL.
countrystring, 2 lettersnoThe payee's country. It helps the server infer the right rail.
instrument.kindFIAT_BANK or CRYPTO_ADDRESSyesA bank account, or a wallet.
instrument.currencystringfor FIAT_BANKISO 4217.
instrument.railstringnoOptional override — the rail is inferred from country and currency by default.
instrument.chainstringfor CRYPTO_ADDRESSThe 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.networksarray of stringsnoEVM 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.detailsobjectyesThe destination's identifying fields: iban / accountNumber / bicSwift / sortCode / routingNumber / bankCountry for a bank account, address (and optional memo) for a wallet.
instrument.labelstringnoYour free-text label for this destination.
instrument.travelRuleobjectfor CRYPTO_ADDRESSWhether the wallet is self-hosted (selfHosted), and the institution holding it (vaspName) when it is not. A wallet registration without this capture is refused.
nicknamestringnoYour display alias for the payee.
idempotencyKeystring, up to 100 charsnoAlternative 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.

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

  • status is PENDING_APPROVAL and usable is false. That is the normal, expected answer — see the callout above. usableAt stays null until TCB approval starts the holding clock.
  • screening.status is where screening stands. CLEAR passed; REVIEW is usable-when-activated but flagged (a matching warning explains); HIT means the destination came back BLOCKED; UNSCREENED means 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) and DUPLICATE_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.

instrument for a wallet
{
  "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:

FieldWhat it tells you
addressFamilyEVM 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[].statusACTIVE — 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.
chainThe primary ACTIVE network.
reachableChainsThe 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.
extensibletrue 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.

Request
curl "https://api.threecommabank.com/api/partner/v1/beneficiaries/address-info?address=0x8ba1f109551bd432803012645ac136ddd64dba72" \
  -H "Authorization: Bearer $TCB_API_KEY"
Response 200
{
  "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.

instrument on three networks
{
  "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.

  1. Fetch the challenge. It returns message, nonce and expiresAt; the challenge is valid for 15 minutes and only for this destination.
  2. Have the wallet sign message unchanged with personal_sign (EIP-191).
  3. Submit { "message": "…", "signature": "0x…" }. The response is the destination, with ownership.proven: true and extensible recomputed.

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

Request body
{ "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.activated at POST /webhooks. It fires when the destination became usable — approval granted and holding period elapsed — and points at GET /beneficiaries/{id}. beneficiary.blocked is its counterpart: screening or a TCB operator blocked the destination.
  • Poll GET /beneficiaries/{id} and read usable. The read is authoritative: whatever events you have or have not received, usable: true on 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.

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

StatusCodeWhat 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.
400IDEMPOTENCY_KEY_REQUIRED / IDEMPOTENCY_KEY_INVALIDSend a key, in one place, within the allowed characters and length.
400INVALID_AMOUNTA malformed limit or status query value on the list route.
403INSUFFICIENT_SCOPEThe key lacks payee:manage or payee:read.
404BENEFICIARY_NOT_FOUNDNo destination with that id in your scope — also when iid is not an instrument of that destination.
409DUPLICATE_INSTRUMENTYou already hold these details live — existingBeneficiaryId and existingInstrumentId name where. Use the existing destination, or remove it first if you meant to replace it.
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.
400 / 409NETWORK_NOT_REACHABLEA 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 / 409NETWORK_NOT_EXTENSIBLEThe 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.
400OWNERSHIP_PROOF_INVALIDNot a current challenge for this destination, or not signed by the address's key. Fetch a new challenge and sign it unchanged.
409NETWORK_ALREADY_ACTIVEAlready payable on that network.
409NETWORK_PENDING_APPROVALYou already added that network; it awaits TCB approval.
409NETWORK_NOT_ADDEDThe network could not be added. Fetch the destination again: its status may have changed.
409LAST_NETWORKThe destination's only ACTIVE network. Remove the destination instead.
409NETWORK_ALREADY_RETIREDThat network is already retired.
409DUPLICATE_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.
409INSTRUMENT_NOT_MUTABLEThe 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.

Pilot disclosure

On this page