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

Payouts

Pay a registered destination from one of your fiat accounts.

Concepts: Intents and the money lifecycle

A payout to a third party is a two-step contract. You register a destination first — it must clear screening, TCB approval and its holding period — and only then can a payout name it. This page is the second step: it pays a destination that already completed that lifecycle. There is no way to pass raw bank details on this call; the only way to name a destination is the instrumentId a registration returned.

You can also pay with one of your sub-clients' money — see Pay on behalf of a sub-client.

The fiat rail is mocked, as the disclosure above states: the full control path runs — the destination lifecycle, your money caps, your balance, screening, the send-time re-check — and the last hop, the actual bank transfer, is simulated by a sandbox rail. No real money moves.

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 payout. On this route the key is remembered permanently. The full rule, including how parameters are compared, is in Conventions.

Follow the state ladder

GET /payouts/{id} reports where a payout stands.

StatusWhat it meansWhat to do
PENDINGAccepted, nothing has been debited yet.Wait or poll.
HELD_FOR_REVIEWYour account is debited and TCB is reviewing the payment before it goes out. Nothing has been sent yet.Wait. It moves to PROCESSING when released (payout.released), or ends FAILED with the debit refunded (payout.failed). Do not re-key.
PROCESSINGYour account is debited and the rail has the payment.Wait or poll.
SETTLEDTerminal. The rail confirmed it.Done — unless the receiving bank later returns it, see below.
RETURNEDThe receiving bank sent a settled payment back. The amount is re-credited to your source account.Nothing — reconcile against the record.
FAILEDTerminal. The rail refused it, or it never reached the rail. Nothing left your account — a taken debit was refunded.Safe to retry under a new key once the cause is fixed.
FAILED_PENDING_COMPENSATIONThe payment was handed to the rail and its outcome could not be established. Money may have left.TCB resolves it. Do not retry.
COMPENSATEDTerminal. TCB resolved it. compensation.outcome says how.Read the object for the outcome.

A payout may be held for review

TCB reviews some payments before they go out, whichever channel they come from. A payout that is flagged answers 201 with status: HELD_FOR_REVIEW: your account is debited, and nothing has been sent. payout.held fires. When TCB releases it, payout.released fires and the payout continues as any other (PROCESSING, then SETTLED). When TCB declines it, the debit is refunded and the payout ends FAILED with payout.failed. A held payout is never released or declined on a timer. Replaying its key answers the payout in its current state.

Never re-key a compensable failure

500 PAYOUT_FAILED_PENDING_COMPENSATION means money may have left. TCB resolves it from the rail's own answer about the payment, or an operator resolves it by hand — see Compensation. Nothing resolves it on a timer. Poll the original key or the payout id; quote payoutId when you contact TCB.

Before anything is charged

Every payout is checked in this order, all of it before a single unit is debited:

  • Your account is not under a rail freeze — 409 PARTNER_RAIL_FROZEN.
  • The amount is well-formed, at the currency's own settlement precision and above the minimum.
  • Your account tree is connected to a rail for that currency — 409 PAYOUT_RAIL_NOT_AVAILABLE if it is not. The rail is resolved for your account tree in that currency, not for the individual account you name as the source; issue an IBAN on your main account first.
  • The request would not cross a money cap TCB set for your account — 400 PAYOUT_CAP_EXCEEDED.
  • The rail the payout travels carries a Sending fee — 409 SENDING_FEE_UNPRICEABLE if no fee is configured for it, or if the destination's rail is not enabled for payouts in that currency. See The Sending fee.
  • The source account holds the amount plus its Sending fee — 400 INSUFFICIENT_BALANCE.
  • The destination is yours, is a bank account, in the payout's currency, carries an IBAN or account number, and is usable — approved by TCB with its holding period elapsed — and not blocked.

The destination is then checked again immediately before the money leaves. A destination blocked between initiation and that moment is refused, the debit is refunded, and the payout answers 403 DESTINATION_BLOCKED or 403 DESTINATION_NOT_USABLE with payoutId in details — the payout itself exists, as FAILED.

The Sending fee

Every payout pays the Sending fee for the rail it travels, the same per-rail fee a payment sent from the TCB app pays. The rail is the destination's rail (a sub-client paying over its account IBAN travels SWIFT). The fee is charged on top of amount, in the payout's currency, from the same source account, at the moment the payout is debited: the destination receives amount, and your account is debited amount plus the fee. The response states it as fee. Your money caps count amount alone.

To see the fee before you commit, quote the payout first and name the quote on the payout: it is then charged the quoted fee.

The fee comes back with the payout: whenever the debit is refunded (FAILED, a decline after review, a COMPENSATED payout with compensation.outcome REFUNDED) or the receiving bank returns the payment (RETURNED), the fee is credited back to the source account too. A held payout is charged the fee it was debited with; nothing is re-priced when it is released.

Quote a payout first

POST /payouts/quote · scope payment:execute · no idempotency key

Quoting is optional and recommended. A quote prices the payout you are about to make without making it: the Sending fee for the rail it will travel, and what leaves your account in total. Nothing is charged.

The quote runs every check in Before anything is charged except the send-time re-check, so a refusal here is the refusal the payout would get. A quote reserves nothing: your balance, your money caps and the destination are checked again when you pay. A quote records nothing against the destination: it does not screen it again (the payout does that when screening is due) and it opens no approval request.

There is one exception to "the refusal the payout would get": a first payment to a destination above your organisation's own first-send cap. The quote answers requiresApproval: true rather than refusing. The payout itself is still refused with 403 DESTINATION_NOT_USABLE until a member of your organisation makes the first payment to that destination through the app, or your organisation raises or removes its first-send cap.

FieldTypeRequiredNotes
instrumentIdUUIDyesThe destination you will pay.
currencystring, 3 lettersyesThe payout currency.
amountdecimal stringyesThe payout amount.
sourceAccountIdUUIDnoThe fiat account the payout will leave, as on the payout.

Send x-on-behalf-of to quote a payout of one of your sub-clients' money.

Request
curl -X POST https://api.threecommabank.com/api/partner/v1/payouts/quote \
  -H "Authorization: Bearer $TCB_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "instrumentId": "1d7a9f42-6b05-4e83-a2c1-8f3b0d6e4c97",
    "currency": "EUR",
    "amount": "1250.00"
  }'
Response 201
{
  "success": true,
  "data": {
    "quoteId": "0b6f2d84-3c19-4e7a-9a52-6d1e8f4c7b30",
    "amount": "1250",
    "currency": "EUR",
    "fee": { "amount": "2.5", "currency": "EUR", "rail": "SEPA" },
    "total": "1252.5",
    "expiresAt": "2026-10-30T10:16:28.000Z",
    "requiresApproval": false,
    "disclosure": { "version": "2026-10-27", "summary": "…", "items": ["…"] }
  },
  "timestamp": "2026-10-30T10:15:28.214Z"
}

The quote holds for 60 seconds. To be charged the quoted fee, name quoteId on the payout before expiresAt. The payout must repeat the quoted request: the same instrumentId, currency, amount and sourceAccountId (the amount compares by value, so "1250" matches "1250.00"), and the same x-on-behalf-of header, or none. A quote pays one payout.

A payout naming a quote is refused, with nothing charged and the idempotency key still usable, when:

  • the quote is past expiresAt: 409 QUOTE_EXPIRED
  • the quote has already paid a payout: 409 QUOTE_ALREADY_USED
  • the request differs from the quoted one: 409 QUOTE_MISMATCH, with details.fields naming what differs
  • the payout would now travel a different rail than the one quoted: 409 QUOTE_TERMS_CHANGED
  • there is no payout quote with that id in your scope: 404 QUOTE_NOT_FOUND

In each case, request a new quote.

Pay a registered destination

POST /payouts · scope payment:execute · idempotency key required

FieldTypeRequiredNotes
instrumentIdUUIDyesThe instrumentId of a destination you registered, which must have completed screening, TCB approval and its holding period. There is no way to pass raw bank details here.
currencystring, 3 lettersyesMust equal the destination's currency — a payout is single-currency. Selects the fiat account the money leaves.
amountdecimal stringyesNever a JSON number. No more fractional digits than the currency settles at. 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.
referencestring, 1–140 charsyesYour end-to-end reference for the payment, carried to the rail as the remittance information the receiving bank shows.
quoteIdUUIDnoA quote for this exact payout. The payout is then charged the quoted Sending fee. Optional for now.
idempotencyKeystring, up to 100 charsnoAlternative to the Idempotency-Key header.
Request
curl -X POST https://api.threecommabank.com/api/partner/v1/payouts \
  -H "Authorization: Bearer $TCB_API_KEY" \
  -H "Idempotency-Key: acme-payout-2026-09-06-0001" \
  -H "Content-Type: application/json" \
  -d '{
    "instrumentId": "1d7a9f42-6b05-4e83-a2c1-8f3b0d6e4c97",
    "currency": "EUR",
    "amount": "1250.00",
    "reference": "INV-2026-0042"
  }'
Response 201
{
  "success": true,
  "data": {
    "payoutId": "6a2f9c14-3e75-4b60-9d18-7a5f0c2e4b83",
    "status": "PROCESSING",
    "idempotencyKey": "acme-payout-2026-09-06-0001",
    "replayed": false,
    "currency": "EUR",
    "amount": "1250",
    "fee": { "amount": "0", "currency": "EUR" },
    "reference": "INV-2026-0042",
    "destination": {
      "beneficiaryId": "9b3e5c17-2a80-4f64-b1d9-6c0e8a4f2d53",
      "instrumentId": "1d7a9f42-6b05-4e83-a2c1-8f3b0d6e4c97",
      "name": "Acme Supplies GmbH",
      "kind": "FIAT_BANK",
      "currency": "EUR",
      "rail": "SEPA",
      "last4": "3000"
    },
    "sourceAccountId": "7e2a0c53-4d81-49b6-8f37-1a5c9e0b2d74",
    "endToEndId": "8c1e7b25-4a93-4f06-b83d-51a0d7c46e29",
    "submittedAt": "2026-09-06T10:15:31.000Z",
    "settledAt": null,
    "failedAt": null,
    "returnedAt": null,
    "return": null,
    "compensation": null,
    "createdAt": "2026-09-06T10:15:28.000Z",
    "disclosure": { "version": "2026-08-27", "summary": "…", "items": ["…"] }
  },
  "timestamp": "2026-09-06T10:15:31.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 four fields carefully:

  • status is PROCESSING once the rail has accepted the payment, which is when submittedAt is set. Wait or poll for a terminal state.
  • fee is the Sending fee charged with this payout, on top of amount. It reads "0" when the rail carries no fee, and is null only on a payout refused before anything was debited.
  • endToEndId is the identifier TCB gave the rail for this payment — what a receiving bank or a statement line quotes. It is null until the payout has been debited.
  • destination is a summary of the destination as it stood when the payout was made — the payee's name, its kind, currency, rail and the last four characters of the account identifier — not a link you have to follow back to GET /beneficiaries/{id}.

When it comes back

A settled payout is not always final: the receiving bank can send it back. When that happens the payout's status reads RETURNED, returnedAt is set, and return.reasonCode carries the scheme's own code verbatim, when the rail gave one. The amount is re-credited to the source account the payout debited — see return.recreditedAmount — and the Sending fee with it. There is nothing to do beyond reconciling: the payout stays a completed record, and the fiat is back on your account. A return can also arrive while the payout is still PROCESSING: payout.settled and payout.returned then fire together, and the payout reads RETURNED.

A payment you made on behalf of a sub-client comes back differently — see there.

Fetch a payout

GET /payouts/{id} · scope transaction:read

Returns the object in its current state, including a terminal failure, which is reported as the object rather than as an error.

Request
curl https://api.threecommabank.com/api/partner/v1/payouts/6a2f9c14-3e75-4b60-9d18-7a5f0c2e4b83 \
  -H "Authorization: Bearer $TCB_API_KEY"

An id that is not yours, or that belongs to a different flow, answers 404 INTENT_NOT_FOUND.

Pay on behalf of a sub-client

Send x-on-behalf-of: <subClientId> on POST /payouts and the payment is that sub-client's, not yours:

  • The money leaves the sub-client's own account. Omit sourceAccountId and TCB takes the sub-client's account in that currency; name one and it must be that sub-client's. Naming one of your own accounts answers 404 SOURCE_ACCOUNT_NOT_FOUND, which is also what a sub-client you do not hold answers — an account you are not acting for is not visible on this call.
  • The sub-client is the sending party. The payment goes out over the sub-client's own rail and names it, never you. When it goes out over a local rail the sub-client holds in that currency, the originator is that local rail's own account (for EUR its Luxembourg IBAN, for USD its account number and routing number), and the sub-client's registered name travels as the ultimate debtor. When it goes out over SWIFT, the originator is the sub-client itself, on its account IBAN. A sub-client with no account details of its own in that currency is refused with 409 SUB_CLIENT_RAIL_COORDINATE_MISSING, and one whose payment would go over SWIFT in a currency SWIFT cannot carry with 409 SUB_CLIENT_SWIFT_ROUTE_UNAVAILABLE — nothing is charged either way. Issue it an account in that currency first.
  • The destination is still from your registry. A sub-client holds no destinations of its own. Register the destination the ordinary way; a destination you filed for a different sub-client is not payable from this one and answers 404 BENEFICIARY_NOT_FOUND, the same answer an unknown instrumentId gets.
  • The sub-client must be cleared to send money out. It needs the sendExternal capability — read it at GET /sub-clients/{id}. Without it the payout is refused with 409 SUB_CLIENT_CAPABILITY_UNAVAILABLE before anything is charged.
  • Your money caps still apply. A sub-client's payout counts against the ceilings TCB set for your account.
  • A returned payment is put back by TCB, not on the wire. If the receiving bank sends a sub-client's payment back, TCB returns the money to that sub-client's own account by hand. The payout keeps the state it settled in, payout.returned does not fire for it, and the re-credit is not one you will see arrive automatically. Tell TCB if a payment you made for a sub-client comes back.

The header is part of the payment, not a view over it. The same idempotency key sent with and without it is two different payments, and the second answers 409 IDEMPOTENCY_KEY_REUSED.

Reads take the same header. GET /payouts/{id} serves a sub-client's payout only when you name that sub-client again; asking without the header, or naming a different one, answers 404 INTENT_NOT_FOUND. The payout.* webhooks carry resource.onBehalfOf — the sub-client id to send back on the fetch — on exactly those payouts, and nothing on your own.

Request
curl -X POST https://api.threecommabank.com/api/partner/v1/payouts \
  -H "Authorization: Bearer $TCB_API_KEY" \
  -H "x-on-behalf-of: c4f81e2a-9b60-4d37-a512-8e0f3c6b7d95" \
  -H "Idempotency-Key: payout-2026-09-17-0001" \
  -H "Content-Type: application/json" \
  -d '{
    "instrumentId": "b7d1f0a3-5c82-4e91-a604-2f8b3d1c7e05",
    "amount": "1250.00",
    "currency": "EUR",
    "reference": "INV-2026-0912"
  }'

Money caps

TCB may set ceilings on your payouts, per currency, the same way it does for deposits, withdrawals and conversions — payouts are capped independently of every other flow. maxTicket, daily and monthly all apply; maxOutstanding does not, because a payout has no notion of a net contributed position to hold against. A request that would cross one is refused before anything moves with 400 PAYOUT_CAP_EXCEEDED: details.cap names the ceiling, details.limit its value, and details.attempted what the figure would have become.

A payout counts toward daily/monthly as soon as it is accepted, including one still in flight or in a compensable state — only a payout that terminally failed with nothing moved stops counting.

Handle the errors

Nothing has been charged on any 4xx, or on a 503. The two 500s say exactly what happened.

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 account does not hold the amount plus its Sending fee. details.available says what it does hold, details.required what the payout needs and details.fee the Sending fee.
400PAYOUT_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.
400DESTINATION_KIND_NOT_SUPPORTEDThe destination is a wallet. This call pays bank destinations only.
400DESTINATION_CURRENCY_MISMATCHThe destination's currency does not match currency.
400DESTINATION_INCOMPLETEThe destination carries neither an IBAN nor an account number. Register a corrected destination.
403DESTINATION_NOT_USABLEStill serving its holding period, awaiting TCB approval, removed, or superseded. details.status and details.usableAt say which; also carries details.frozenUntil where relevant. With details.approvalReason, the first payment to the destination is above your organisation's own first-send cap, which this API cannot clear: a member of your organisation makes the first payment to it through the app, which records its first use, or your organisation raises or removes the cap.
403DESTINATION_BLOCKEDScreening matched it, or a TCB operator blocked it. Contact TCB if you believe it is wrong.
403INSUFFICIENT_SCOPEThe key lacks payment:execute or transaction:read.
404BENEFICIARY_NOT_FOUNDThe named destination is not one of yours, or — acting for a sub-client — it is filed for a different one.
404SOURCE_ACCOUNT_NOT_FOUNDThe named source account is not one of yours, or — acting for a sub-client — is not that sub-client's.
404ON_BEHALF_OF_NOT_FOUNDx-on-behalf-of does not name one of your sub-clients.
404INTENT_NOT_FOUNDNo payout with that id in your scope.
400 or 403SOURCE_ACCOUNT_NOT_USABLE400 when the named account is not a fiat account in that currency; 403 when it is not active. Name another account.
409SOURCE_ACCOUNT_AMBIGUOUSYou omitted sourceAccountId and hold more than one account in that currency. Name one.
409PAYOUT_RAIL_NOT_AVAILABLENo fiat rail is connected to your account tree for that currency — the rail is resolved for the account tree, not for the individual source account. Issue an IBAN on your main account, or contact TCB.
409SENDING_FEE_UNPRICEABLENo Sending fee is configured for the rail the payout would travel, or the destination's rail is not enabled for payouts in that currency. Nothing charged and the key stays usable. Contact TCB.
409CURRENCY_NOT_CONFIGUREDThat currency is not configured for this rail. Contact TCB.
409SUB_CLIENT_RAIL_COORDINATE_MISSINGThe sub-client has no account details of its own in that currency to pay from. Issue it an account in that currency, or contact TCB.
409SUB_CLIENT_SWIFT_ROUTE_UNAVAILABLEThe sub-client has no local rail in that currency, so the payment would leave over SWIFT from its account IBAN, and SWIFT cannot carry that currency. Contact TCB.
409SUB_CLIENT_CAPABILITY_UNAVAILABLEThe sub-client is not cleared to send money out. details.reason says which fix applies — see the reason table.
409PARTNER_RAIL_FROZENPayouts are suspended for your account after an unexplained difference between your recorded position and what TCB holds. A person lifts it; contact TCB.
404QUOTE_NOT_FOUNDNo payout quote with that quoteId in your scope. Request a quote.
409QUOTE_EXPIREDThe quote is past its expiresAt. Request a new quote.
409QUOTE_ALREADY_USEDThe quote already paid a payout. Request a new quote.
409QUOTE_MISMATCHThe request differs from the quoted one; details.fields names what differs. Send the quoted request, or request a new quote.
409QUOTE_TERMS_CHANGEDThe payout would now travel a different rail than the one quoted, so the quoted fee does not apply. Request a new quote.
409IDEMPOTENCY_KEY_REUSEDSame key, different parameters. A quoteId is one of the parameters. Use a new key.
409IDEMPOTENT_REQUEST_IN_PROGRESSThe first call under this key is still running. Retry the same key.
409PAYOUT_FAILEDReplay of a key whose payout already ended this way. Nothing left your account. Submit a new payout under a new key.
500PAYOUT_FAILEDThe rail refused the payment. details.reasonCode says why. Nothing left your account — a taken debit was refunded. Safe to retry under a new key once the cause is fixed.
500PAYOUT_FAILED_PENDING_COMPENSATIONMoney may have left. Do not re-key — see the callout above.
503SCREENING_UNAVAILABLEScreening could not run for this destination kind, which fails closed. Retry.

The full catalogue is in Errors.

Receive the events

Subscribe at POST /webhooks to payout.settled, payout.failed, payout.returned, payout.held and payout.released. Events are addressed to you, whoever the payment was for; a payout made on a sub-client's behalf carries resource.onBehalfOf, which is the x-on-behalf-of header the resource fetch needs. 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