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.
| Status | What it means | What to do |
|---|---|---|
PENDING | Accepted, nothing has been debited yet. | Wait or poll. |
HELD_FOR_REVIEW | Your 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. |
PROCESSING | Your account is debited and the rail has the payment. | Wait or poll. |
SETTLED | Terminal. The rail confirmed it. | Done — unless the receiving bank later returns it, see below. |
RETURNED | The receiving bank sent a settled payment back. The amount is re-credited to your source account. | Nothing — reconcile against the record. |
FAILED | Terminal. 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_COMPENSATION | The payment was handed to the rail and its outcome could not be established. Money may have left. | TCB resolves it. Do not retry. |
COMPENSATED | Terminal. 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_AVAILABLEif 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_UNPRICEABLEif 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.
| Field | Type | Required | Notes |
|---|---|---|---|
instrumentId | UUID | yes | The destination you will pay. |
currency | string, 3 letters | yes | The payout currency. |
amount | decimal string | yes | The payout amount. |
sourceAccountId | UUID | no | The 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.
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"
}'{
"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, withdetails.fieldsnaming 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
| Field | Type | Required | Notes |
|---|---|---|---|
instrumentId | UUID | yes | The 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. |
currency | string, 3 letters | yes | Must equal the destination's currency — a payout is single-currency. Selects the fiat account the money leaves. |
amount | decimal string | yes | Never a JSON number. No more fractional digits than the currency settles at. Amounts below 0.000001 are refused. |
sourceAccountId | UUID | no | The fiat account to debit. Omitted, your single active account in that currency is used — see naming the right account. |
reference | string, 1–140 chars | yes | Your end-to-end reference for the payment, carried to the rail as the remittance information the receiving bank shows. |
quoteId | UUID | no | A quote for this exact payout. The payout is then charged the quoted Sending fee. Optional for now. |
idempotencyKey | string, up to 100 chars | no | Alternative to the Idempotency-Key header. |
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"
}'{
"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:
statusisPROCESSINGonce the rail has accepted the payment, which is whensubmittedAtis set. Wait or poll for a terminal state.feeis the Sending fee charged with this payout, on top ofamount. It reads"0"when the rail carries no fee, and isnullonly on a payout refused before anything was debited.endToEndIdis the identifier TCB gave the rail for this payment — what a receiving bank or a statement line quotes. It isnulluntil the payout has been debited.destinationis 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 toGET /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.
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
sourceAccountIdand 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 answers404 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 with409 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 unknowninstrumentIdgets. - The sub-client must be cleared to send money out. It needs the
sendExternalcapability — read it atGET /sub-clients/{id}. Without it the payout is refused with409 SUB_CLIENT_CAPABILITY_UNAVAILABLEbefore 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.returneddoes 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.
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.
| Status | Code | What to do |
|---|---|---|
| 400 | IDEMPOTENCY_KEY_REQUIRED / IDEMPOTENCY_KEY_INVALID | Send a key, in one place, within the allowed characters and length. |
| 400 | INVALID_AMOUNT | amount was not a positive decimal string, or was below 0.000001. |
| 400 | AMOUNT_PRECISION_EXCEEDED | Round to details.allowedDecimals and resubmit. |
| 400 | INSUFFICIENT_BALANCE | The 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. |
| 400 | PAYOUT_CAP_EXCEEDED | The 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. |
| 400 | DESTINATION_KIND_NOT_SUPPORTED | The destination is a wallet. This call pays bank destinations only. |
| 400 | DESTINATION_CURRENCY_MISMATCH | The destination's currency does not match currency. |
| 400 | DESTINATION_INCOMPLETE | The destination carries neither an IBAN nor an account number. Register a corrected destination. |
| 403 | DESTINATION_NOT_USABLE | Still 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. |
| 403 | DESTINATION_BLOCKED | Screening matched it, or a TCB operator blocked it. Contact TCB if you believe it is wrong. |
| 403 | INSUFFICIENT_SCOPE | The key lacks payment:execute or transaction:read. |
| 404 | BENEFICIARY_NOT_FOUND | The named destination is not one of yours, or — acting for a sub-client — it is filed for a different one. |
| 404 | SOURCE_ACCOUNT_NOT_FOUND | The named source account is not one of yours, or — acting for a sub-client — is not that sub-client's. |
| 404 | ON_BEHALF_OF_NOT_FOUND | x-on-behalf-of does not name one of your sub-clients. |
| 404 | INTENT_NOT_FOUND | No payout with that id in your scope. |
| 400 or 403 | SOURCE_ACCOUNT_NOT_USABLE | 400 when the named account is not a fiat account in that currency; 403 when it is not active. Name another account. |
| 409 | SOURCE_ACCOUNT_AMBIGUOUS | You omitted sourceAccountId and hold more than one account in that currency. Name one. |
| 409 | PAYOUT_RAIL_NOT_AVAILABLE | No 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. |
| 409 | SENDING_FEE_UNPRICEABLE | No 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. |
| 409 | CURRENCY_NOT_CONFIGURED | That currency is not configured for this rail. Contact TCB. |
| 409 | SUB_CLIENT_RAIL_COORDINATE_MISSING | The 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. |
| 409 | SUB_CLIENT_SWIFT_ROUTE_UNAVAILABLE | The 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. |
| 409 | SUB_CLIENT_CAPABILITY_UNAVAILABLE | The sub-client is not cleared to send money out. details.reason says which fix applies — see the reason table. |
| 409 | PARTNER_RAIL_FROZEN | Payouts are suspended for your account after an unexplained difference between your recorded position and what TCB holds. A person lifts it; contact TCB. |
| 404 | QUOTE_NOT_FOUND | No payout quote with that quoteId in your scope. Request a quote. |
| 409 | QUOTE_EXPIRED | The quote is past its expiresAt. Request a new quote. |
| 409 | QUOTE_ALREADY_USED | The quote already paid a payout. Request a new quote. |
| 409 | QUOTE_MISMATCH | The request differs from the quoted one; details.fields names what differs. Send the quoted request, or request a new quote. |
| 409 | QUOTE_TERMS_CHANGED | The payout would now travel a different rail than the one quoted, so the quoted fee does not apply. Request a new quote. |
| 409 | IDEMPOTENCY_KEY_REUSED | Same key, different parameters. A quoteId is one of the parameters. Use a new key. |
| 409 | IDEMPOTENT_REQUEST_IN_PROGRESS | The first call under this key is still running. Retry the same key. |
| 409 | PAYOUT_FAILED | Replay of a key whose payout already ended this way. Nothing left your account. Submit a new payout under a new key. |
| 500 | PAYOUT_FAILED | The 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. |
| 500 | PAYOUT_FAILED_PENDING_COMPENSATION | Money may have left. Do not re-key — see the callout above. |
| 503 | SCREENING_UNAVAILABLE | Screening 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.