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

Settlements

Pay another client of the bank instantly on CommaNet — choose the handle you are paid by, confirm who you are paying, and settle.

Concepts: Banking as a service

Most money that leaves an account leaves the bank. Some of it does not: when the account you are paying belongs to another client of TCB, the movement never touches an external rail at all. CommaNet is that case made explicit — same currency, instant, final on the response, and free.

Three surfaces make it work, and they are separate on purpose. A profile decides how you are addressed. Resolve tells you who you are about to pay, in their own verified legal name, before any money moves. Settle performs the movement and gives both parties one row to read.

Three rails, and each refuses the other two's work

  • POST /transfers moves money inside your own tree — your accounts and your sub-clients'. It refuses a movement that leaves your tree with 422 TRANSFER_NOT_WITHIN_TREE.
  • POST /settlements moves money to another client of the bank. It refuses a movement inside one tree with 422 SETTLEMENT_NOT_CROSS_TREE.
  • POST /payouts pays a third party outside the bank, through a registered destination.

Each refusal names the route you wanted, so a call on the wrong rail costs you one round trip and never a wrong movement.

Choose how you are paid

GET /network/profile · PUT /network/profile · scope settlement:read / settlement:manage

Your profile is how the rest of the network addresses you. It carries five things:

FieldWhat it does
handleThe short name you are paid by, e.g. acme-treasury. Lowercase letters, digits and single internal hyphens, 3–32 characters per part.
discoverableWhether you appear in directory search. Off by default.
receivePolicyANYONE accepts settlements from any member. CONNECTED_ONLY accepts them only from members on your connections list — see Choose who may pay you.
aliasesTrading names you are also known by, at most five.
inboundConversionCurrencyConvert what you receive into this currency on arrival. null keeps what you are paid. See Convert what you receive.

A handle is chosen once

PUT /network/profile sets your handle the first time and never changes it: sending a different one afterwards is refused with 409 HANDLE_IMMUTABLE. A handle that could be re-issued is a handle somebody's saved payee could quietly start pointing at a different company, so changing one is a support action that reserves the old one forever. Everything else on the profile stays amendable.

Uniqueness is not decided on the literal text. Handles are compared on a folded form in which characters a human reads alike are the same character — rn and m, zero and o, one and l and i — so a handle that merely looks like one already taken answers 409 HANDLE_TAKEN. A short list of names is reserved outright and answers 409 HANDLE_RESERVED.

Acting with x-on-behalf-of, the profile is that sub-client's, and its handle is namespaced under yours:

acme                  your own handle
acme/zayn-retail      one of your sub-clients

The prefix must be your handle exactly, so choose your own before your sub-clients' — otherwise 409 ROOT_HANDLE_REQUIRED. Sub-clients are never listed in directory search, whatever discoverable says.

Choose who may pay you

GET /network/connections · POST /network/connections · DELETE /network/connections/{memberClientAccountId} · scope settlement:read / settlement:manage

With receivePolicy set to CONNECTED_ONLY, a settlement to you is accepted only when the sending node is on your connections list. Everyone else is refused with 422 RECEIVE_POLICY_REFUSED and nothing is written. The list is yours alone: you add a member by their exact handle, it takes effect on the next settlement, and nothing is asked of them — they are not told they are listed.

POST /network/connections
{ "handle": "acme-treasury", "note": "Prime broker — onboarded 2026-09" }

201
{
  "id": "…",
  "memberClientAccountId": "…",
  "handle": "acme-treasury",
  "legalName": "Acme Trading Ltd",
  "note": "Prime broker — onboarded 2026-09",
  "createdAt": "2026-09-14T09:00:00.000Z"
}

An empty list refuses everyone

Switching to CONNECTED_ONLY with nobody listed means nobody can pay you over the network. Build the list first — it may be built while you are still on ANYONE, where it is dormant — and switch afterwards.

The list is matched on the exact node, not on its root. A sub-client is its own originator of record, so connecting acme does not connect acme/zayn-retail; list each counterparty you accept. Adding a member already listed returns the existing row unchanged, so a retry is safe. A member inside your own tree is refused with 422 CONNECTION_INVALID — money moves to it as a transfer, never a settlement, so there is nothing for the list to admit. DELETE takes effect on the next settlement and changes nothing already settled; a member who was not listed answers 404 CONNECTION_NOT_FOUND.

With x-on-behalf-of, the list is that sub-client's.

Convert what you receive

PUT /network/profile · scope settlement:manage

A settlement is same-currency: one payment settles one currency, and if the sender's money was in another one the conversion was the sender's. If you would rather hold everything you receive in one currency, set inboundConversionCurrency:

{ "inboundConversionCurrency": "EUR" }

From then on a settlement paid to you in any other currency is sold for EUR the moment it lands, on your own accounts, priced the way your conversions normally are. Three things do not change:

  • The settlement. It settled, and is final, in the currency the sender chose. The sender's read of it, its amount and currency, the webhook and the return-of-funds path are exactly as they would be without the rule.
  • The sender's view. What you do with money that is final and yours is not the sender's to see. Only your read of the settlement carries receipt.
  • Your money. If the conversion cannot be priced, nothing is retried on a clock and nothing is lost: the funds stay in the currency they arrived in and you are told. Convert them yourself when you choose.

You must already hold an active fiat account in the currency you name; otherwise the call answers 422 INBOUND_CONVERSION_CURRENCY_UNAVAILABLE and the rule is unchanged. null clears it. A settlement already in your rule's currency is left alone. A return of funds lands in the currency it was sent, never re-converted at a second price.

Confirm who you are paying

GET /network/resolve?handle= | ?iban= · scope settlement:read

Resolve is confirmation of payee, on us. Supply exactly one of handle or iban — a TCB IBAN identifies a member just as well as a handle does — and optionally name, the name you believe you are paying.

{
  "node": { "clientAccountId": "…", "kind": "CLIENT" },
  "legalName": "Acme Trading Ltd",
  "handle": "acme-treasury",
  "receivePolicy": "ANYONE",
  "connected": false,
  "currencies": ["EUR", "USD"],
  "verdict": "MATCH",
  "resolveToken": "…",
  "sameRoot": false,
  "route": {
    "rail": "COMMANET",
    "onUs": true,
    "fee": { "amount": "0", "currency": "USD" },
    "arrival": "INSTANT",
    "label": "via CommaNet · instant · no fee"
  }
}

legalName is the verified legal name and it is the real safety net; the handle is convenience. currencies is what that member has an active account to receive — settling a currency that is not listed answers 422 RECEIVER_HAS_NO_ACCOUNT.

verdict grades the name you supplied against the legal name and the trading names, ignoring case, accents and punctuation:

VerdictMeaning
MATCHYour name is the legal name.
CLOSE_MATCHIt is one of their trading names, or close enough to be a typo.
NO_MATCHIt is neither. Look again before you pay.
nullYou supplied no name. This is the absence of a check, not a passed one.

Cite the token when you settle

resolveToken is signed, lasts ten minutes, and is bound to the client account that resolved — a token issued to someone else is refused. Pass it as resolveToken on POST /settlements and the target is read out of the token, not out of your request body — which is what makes "the member I was shown is the member who was paid" a property of the system rather than a hope. An expired or altered token answers 400 RESOLVE_TOKEN_INVALID; resolve again.

connected says whether you — the acting node — are on that member's connections list. When their receivePolicy is CONNECTED_ONLY and connected is false, a settlement from you will be refused; ask them to add your handle before you pay. Under ANYONE the field is informational.

sameRoot: true means the member you resolved is in your own tree. That is a legitimate lookup, but settling to them is refused — use POST /transfers.

route says how a payment to this member would be carried, so a review screen can show the rail, the fee and the arrival before anyone commits to anything. Branch on rail and render label; never parse the label. COMMANET is a settlement to another client; INTERNAL follows sameRoot: true and belongs on POST /transfers. Both are on us, both are instant, and both are free — the fee is zero in every currency and is published as zero.

Every failure answers one 404 NOT_ON_NETWORK, with one body. A member who does not exist, one who is not verified, one who is frozen and an IBAN that is not ours are deliberately indistinguishable: telling them apart would let anyone read the state of another customer's account by guessing handles.

Search the directory

GET /network/search?q= · scope settlement:read

A bounded prefix search over members who turned discoverable on. It matches handle, legal name and trading names; it needs at least two characters; it returns at most 20 rows, cursor-paginated.

{ "items": [{ "handle": "acme-treasury", "legalName": "Acme Trading Ltd", "kind": "CLIENT" }], "nextCursor": null }

It never returns IBANs, never a member who is not discoverable, and never your own tree. Being payable and being listed are different things: an exact handle or IBAN resolves anyone, discoverable or not.

Settle

POST /settlements · scope settlement:execute

Paying a TCB IBAN is already a settlement

You do not have to choose this endpoint to use the network. When a payment names a beneficiary whose IBAN is one of ours, it settles here — same instant, same zero fee — instead of leaving over an external rail and coming back. The route on the response says which rail carried it.

{
  "fromAccountId": "1b9d4f07-6c23-4a8e-b510-9f2c7d3e5a68",
  "resolveToken": "…",
  "amount": "1500.00",
  "currency": "USD",
  "endToEndId": "ACME-INV-2026-0042",
  "remittance": { "invoice": "INV-2026-0042" },
  "idempotencyKey": "acme-2026-09-08-0001"
}

Name the account to debit and exactly one way of naming who is paid — resolveToken, instrumentId (a destination you registered with kind INTERNAL_TCB, see Destinations), or toAccountId. Naming none or two is refused with 400 SETTLEMENT_INVALID.

endToEndId is your own reference in the ISO 20022 shape, at most 35 characters. It is preserved verbatim and reaches both sides untouched, as does the optional structured remittance beside it (at most 1 KB of JSON). Both are on the settlement, on the statement line, and on every later read.

What has to be true for a settlement to post:

  1. You are allowed to send out of your tree. A settlement leaves your tree, so it gets the same treatment an outbound payment gets: your client account must have completed verification, and a sub-client must be open, hang off a verified client, and hold the sendExternal capability.
  2. The source account is yours, active, fiat, and in this currency. Acting on behalf of a sub-client, it must be that sub-client's own account.
  3. The receiver can be paid — the member exists, is verified, is not frozen, and holds an active account in this currency. The check runs again here, at send time, because a member can change state between the day you saved them and the day you pay them.
  4. It is within your limits — the ceilings TCB sets for you, which a settlement draws down the same way a withdrawal does.
  5. The balance covers it, checked against the locked row inside the posting.

Then the two accounts move, in one transaction. There is no pending state to poll.

A sub-client cannot settle out of your tree by default

sendExternal is not part of the capability set a sub-client is created with — it holds receive and sendInternal, which let it move money inside your tree. Settling to another client of the bank is a different act, so it is granted per sub-client by TCB rather than by default. A sub-client without it answers 409 SENDER_NOT_ELIGIBLE; ask TCB to grant it, or settle from your own client account.

This API posts a settlement your approval rules let through and refuses one they would hold; it never holds one

A settlement made from your own account with an API key is measured against your organisation's approval rules, the same rules the app applies. One the rules let through posts. One they would hold for approval, or block, answers 409 SETTLEMENT_REQUIRES_APPROVAL rather than posting or waiting: this API cannot hold a settlement for a colleague to approve, so that one has to be made in the app — see below.

The fee is zero, and it is still posted

fee is "0" on every settlement in this pilot, and it is a real ledger entry rather than an omission — the balanced posting path is live from the first settlement so that a fee, if one ever exists, is a number rather than a new code path written against live money.

Acting with x-on-behalf-of, the settlement records the sub-client's legal name as the originator, never yours. Both legal names are frozen on the row at the moment it settles, so a later rename never rewrites what a past settlement says.

When an approval control applies to a settlement

Two surfaces reach this rail, and they treat an approval control differently.

In the app, a settlement a member raises is measured against your organisation's approval rules, which decide whether this movement needs approval and from whom. One that does is held, not refused, and posts when a colleague releases it. With no rules and no approval policy of your own, nothing needs an approval: the settlement posts on the sender's own confirmation.

Through this API, a settlement from your own account is measured against the same rules, with the API key as the one starting it. A key is counted on its own: it is not a member of your organisation and it does not stand in for the person who created it.

  • A settlement your rules let through posts. No rule applies to it, a rule says it needs no approval, or it is under the amount a rule sets. With "settlements above $1,000,000 need one approval", a settlement of $10 posts.
  • A settlement your rules would hold for approval is refused with 409 SETTLEMENT_REQUIRES_APPROVAL. Under the same rule, a settlement of $2,000,000 is refused. Nothing is held for a key: make that settlement in the app, where a colleague can approve it.
  • A settlement a rule blocks is refused with the same code.
  • Every member who can approve counts as an approver. A key cannot approve, so a rule that asks for one approval applies even when your organisation has a single member: that member can approve the settlement in the app. A settlement your rules would hold for approval is refused over the API even when nobody is currently able to approve it.
  • A rule limited to the roles that start a payment applies to a key when it asks for an approval or blocks: a rule cannot name an API key yet, so it is applied rather than skipped. A rule that waives approval for particular roles does not waive it for a key.
  • Totals. A rule measured on a total across the organisation counts settlements made with a key together with those made in the app. A rule measured on a total per person keeps a separate total for each key. A settlement that does not post, whether it was refused or failed, counts towards neither.
  • An amount that cannot be priced into a rule's currency at that moment is refused with the same code rather than let through. The message says so; send the same request again shortly.
  • Several settlements at the same moment. When settlements are being measured against your rules at the same time and one cannot be measured on its own, it is answered 409 SETTLEMENT_CONFLICT. Nothing was written; send the same request again.

An approval policy with amount tiers for transfers, withdrawals or settlements is measured on a rolling 24-hour total, not on the single settlement: what the key has settled in the last 24 hours plus this settlement. While that total is at or under the first tier the settlement posts; once it is above, the settlement is refused. With a first tier at $1,000, a key that settles $600 twice within 24 hours has the first posted and the second refused. With no rules and no such policy, the settlement posts.

The same applies wherever a key moves a settlement out of your own account: approving a settlement request, releasing a hold, accepting a return and cutting a netting set.

Raising one through the app returns a hold rather than a settlement:

{
  "status": "PENDING_APPROVAL",
  "transactionId": "…",
  "approvalRequestId": "…",
  "receiver": { "clientAccountId": "…", "accountId": "…", "legalName": "Acme Trading Ltd" },
  "rail": "SETTLEMENT",
  "networkLabel": "CommaNet"
}

Four things are worth knowing about a hold, because each of them is a decision rather than an accident.

Nothing exists on the ledger yet. A held settlement has no settlement row. The pending object is the approval's own transaction; a stl_ id is minted only when the money actually moves. That is why both sides can read a settlement and trust that it means "this happened".

It is checked before it is held, not after. The receiver, your own standing, the currency, the caps — all of it is evaluated when the hold is raised. A payment that could never be made is refused to the person raising it, rather than deferred to a colleague who could only approve it into a failure.

The resolve token is not needed again. Tokens last ten minutes and approvals take as long as a colleague takes. The hold records the account the resolve pointed at, and every receiver check is re-run at execution — so a member frozen while the approval was pending still stops the payment.

Funds are not held aside. The balance is not reserved while the approval is pending, exactly as it is not for a held withdrawal. If it is spent in the meantime, the approved settlement fails on INSUFFICIENT_BALANCE and nothing partial happens. Reserving funds against a named member is a different feature with a different object — see Reserve before you pay below.

On approval the settlement posts and behaves like any other. On rejection nothing moves and no settlement is ever written.

The machine surface (POST /partner/v1/settlements) does not hold. A settlement your approval rules would hold for approval, or block, answers 409 SETTLEMENT_REQUIRES_APPROVAL; raise that payment from the app instead. One the rules let through posts.

Ask to be paid

POST /settlement-requests · GET /settlement-requests · GET /settlement-requests/{id} · POST /settlement-requests/{id}/approve · POST /settlement-requests/{id}/decline · POST /settlement-requests/{id}/cancel

A settlement is a push. A request is the pull: you ask another member for money, and they decide. It is the primitive behind an invoice, a margin call and a funding request.

POST /partner/v1/settlement-requests
{
  "fromAccountId": "7e2a0c53-…",
  "toHandle": "acme-treasury",
  "amount": "1500.00",
  "currency": "USD",
  "endToEndId": "ACME-INV-2026-0042",
  "remittance": { "invoice": "INV-2026-0042" },
  "dueBy": "2026-10-01T12:00:00.000Z"
}

fromAccountId is the account you want credited — the request names where the money should land, not where it comes from. Name the payer by exactly one of toHandle, toIban or toClientAccountId. The reference is yours and travels onto the settlement the payer books, so the invoice number you chose reaches both statements.

Scopes: settlement:manage to raise, decline or cancel; settlement:execute to approve, because approving moves money.

A request id looks like sreq_3f8c1d2a5e6b7049a1b2c3d4e5f60718. Both parties read the same row; direction is OUTBOUND for the member that raised it and INBOUND for the member being asked. GET /settlement-requests takes direction, status, cursor and limit.

The payer approves from an account of their own:

POST /partner/v1/settlement-requests/sreq_…/approve
{ "fromAccountId": "a1c93e6f-…" }

That runs the ordinary settlement — the same checks, the same four ledger entries, the same stl_ row. The request is closed inside the settlement's own transaction, so it can never read APPROVED without a settlement behind it, and two approvals of one request resolve to one payment.

A due date is not a timer

dueBy is a fact on the request, not a job. Nothing on this rail runs on a clock: a request past its due date is marked EXPIRED the next time it is read, or when an approval arrives late and is refused with 409 SETTLEMENT_REQUEST_EXPIRED. Do not wait to be told that a request expired — list your requests, or read the one you care about.

Requests are cross-tree only, like settlements: asking one of your own sub-clients for money is a transfer, and it answers 422 SETTLEMENT_NOT_CROSS_TREE.

Reserve before you pay

POST /settlement-holds · GET /settlement-holds · GET /settlement-holds/{id} · POST /settlement-holds/{id}/release · POST /settlement-holds/{id}/cancel

A hold reserves funds against a named member now, and settles them later — the delivery-versus-payment leg, where you want the money provably set aside before the other side acts.

POST /partner/v1/settlement-holds
{
  "fromAccountId": "7e2a0c53-…",
  "resolveToken": "…",
  "amount": "1500.00",
  "currency": "USD",
  "endToEndId": "ACME-DVP-2026-0042"
}

Name the beneficiary the same three ways POST /settlements does — instrumentId, resolveToken or toAccountId — so a hold and the settlement it becomes can never resolve differently.

Nothing is debited. Your balance is unchanged; the amount simply stops being available on this rail. Available is balance minus the sum of your own ACTIVE holds on that account, and it is what a settlement, a release and a further hold are all checked against. INSUFFICIENT_BALANCE on this rail reports balance, reserved and available in details so you can tell the two apart.

A hold never expires. There is no TTL and no sweeper. It leaves ACTIVE because you released it, because you cancelled it, or because an operator cancelled it under maker-checker. A hold you forget stays reserved.

Releasing settles it:

POST /partner/v1/settlement-holds/shld_…/release

The reserve is consumed by the settlement inside one transaction, so a hold cannot be released twice and a released hold cannot be cancelled. The beneficiary is re-validated at release, not as it was when you reserved: a member frozen in the meantime still stops the payment. Cancelling gives the reserve back and moves nothing.

Caps are drawn down at release, not at reserve — a cap counts money that has left, and a reserve has moved nothing.

Only this rail honours the reserve

A hold is enforced against settlements, releases and further holds. A fiat withdrawal or a within-tree transfer from the same account does not consult it, so it is possible to spend money you have reserved. The consequence is a clean refusal, never a part-payment: the release answers 409 INSUFFICIENT_BALANCE and nothing moves.

Net with a counterparty

POST /netting/agreements · GET /netting/agreements · GET /netting/agreements/{id} · POST /netting/agreements/{id}/accept · POST /netting/agreements/{id}/decline · POST /netting/agreements/{id}/end · POST /netting/agreements/{id}/obligations · GET /netting/agreements/{id}/obligations · GET /netting/obligations/{id} · POST /netting/obligations/{id}/withdraw · POST /netting/agreements/{id}/cut · GET /netting/agreements/{id}/cuts

Two members that pay each other often can offset instead of posting each movement gross. You agree once, per currency; each side then owes amounts into a shared set; and when the set is cut, one ordinary settlement posts for the difference — or none, when the two sides cancel exactly.

POST /partner/v1/netting/agreements
{ "fromAccountId": "7e2a0c53-…", "counterpartyHandle": "orbital-advisory", "currency": "USD" }

The other member accepts by naming its own account under the agreement (POST /netting/agreements/{id}/accept with accountId). Until then the agreement is PROPOSED and nothing can be owed into it. One agreement per currency per pair: a second is refused with 409 NETTING_AGREEMENT_EXISTS.

Netting never extends credit

An obligation reserves its full amount on your agreement account from the moment you submit it, exactly as a hold does: available on this rail is balance minus your ACTIVE holds minus your OPEN obligations, and an obligation you cannot back is refused 409 INSUFFICIENT_BALANCE with reserved and available in details. Between submit and cut nobody is owed anything they cannot collect. Credit and leverage funded through settlement are permanently off the rail; this is what keeps a netting arrangement from becoming a credit book.

POST /partner/v1/netting/agreements/snag_…/obligations
{ "amount": "4000.00", "endToEndId": "ACME-TRADE-2026-0042" }

An OPEN obligation can be withdrawn by its payer (POST /netting/obligations/{id}/withdraw), which releases the reserve. Read the position at any time on the agreement: position.youOwe, owedToYou, netAmount, netPayerClientAccountId and youCut.

The member that owes the net cuts. The party that would pay settles the set, exactly as a sender posts a settlement; the other side is refused 403 NETTING_CUT_NOT_NET_PAYER and is not exposed while it waits, because everything owed to it is reserved. When the net is zero either party may cut.

POST /partner/v1/netting/agreements/snag_…/cut

The cut posts one settlement for the net from the net payer's agreement account to the counterparty's — the same four ledger rows, the same zero fee, the same stl_ row both parties read — with metadata.nettingCutId, a NET-… reference, and the netted obligations listed in remittance.obligations. Every OPEN obligation flips to NETTED with the cut's id in the same transaction. If an obligation was submitted or withdrawn while the cut was posting, the whole cut is refused 409 SETTLEMENT_CONFLICT and nothing is written: read the position again and cut again. The net counts against your WITHDRAWAL cap; the gross figures never do.

Nothing here runs on a clock. An agreement ends because a party ended it (POST /netting/agreements/{id}/end, refused 409 NETTING_HAS_OPEN_OBLIGATIONS while anything is open), an obligation leaves OPEN because its payer withdrew it or a cut consumed it, and a cut happens because the net payer asked for it.

Scopes: settlement:manage to propose, accept, decline, end and withdraw; settlement:execute to owe into the set and to cut, because both commit money. The net is measured against your organisation's approval rules as any settlement is: a cut they let through posts, and one they would hold for approval, or block, answers 409 SETTLEMENT_REQUIRES_APPROVAL here and is made in the app. That is your organisation's control over your own money, so it does not follow a sub-client's: when the net payer is a sub-client acting under x-on-behalf-of, the cut posts. Your sub-clients hold no approval policy of their own — you run whatever control you want over them in your product. Their sendExternal grant is unaffected: without it the cut is still 409 SENDER_NOT_ELIGIBLE.

Ask for money back

POST /settlements/{id}/return-request · GET /settlement-returns · GET /settlement-returns/{id} · POST /settlement-returns/{id}/accept · POST /settlement-returns/{id}/decline · POST /settlement-returns/{id}/cancel

Settlements are final. A return request does not change that: it asks the receiver to send the money back, and the receiver decides.

POST /partner/v1/settlements/stl_…/return-request
{ "reason": "Paid twice against the same invoice" }

Only the sender of a COMPLETED settlement may ask, and only for the full amount — send amount if you like, but anything other than the settlement's own amount answers 422 SETTLEMENT_RETURN_PARTIAL_UNSUPPORTED rather than being approximated.

While the request is open the original settlement stays COMPLETED and the money stays where it is. Nothing is frozen and nothing is reserved.

Accepting books a linked reversing settlement:

POST /partner/v1/settlement-returns/sret_…/accept

The original flips to REVERSED, a new settlement posts in the opposite direction with the same four ledger entries and a zero fee, and both are one transaction. reversalOfId is unique on the settlement row, so a second acceptance answers 409 SETTLEMENT_ALREADY_REVERSED and never a second reversal. The reversing settlement's reference is RET- followed by the original's, and its remittance names the settlement and the return request exactly.

Accepting is a send: it is checked against the receiver's own available balance under a row lock, and it is subject to the receiver's own second-approver policy. It draws down no cap — the full amount goes back to the one account it came from, so it moves no value out. Because it is a send, a sub-client accepting under x-on-behalf-of needs sendExternal exactly as it does to settle, and answers 409 SENDER_NOT_ELIGIBLE without it — so a sub-client that can be paid cannot necessarily give the money back on its own. Ask TCB to grant it.

A return request id looks like sret_3f8c1d2a5e6b7049a1b2c3d4e5f60718. direction is OUTBOUND for the member that asked and INBOUND for the member being asked.

Read a settlement

GET /settlements/{id} · GET /settlements · scope settlement:read

A settlement id looks like stl_3f8c1d2a5e6b7049a1b2c3d4e5f60718. Both parties read the same row, and the only field that differs between them is direction: OUT for the sender, IN for the receiver. A settlement you are not a party to answers 404 — never 403, which would confirm it exists.

GET /settlements lists what the acting node sent or received, newest first, cursor-paginated, optionally narrowed with accountId. To read a sub-client's settlements, act as that sub-client.

activityId groups the settlement with its ledger entries, so a statement can show the movement and its fee as one thing.

Every settlement also names what it is linked to, so you follow an id rather than search a list. Each field is null when nothing is attached:

  • settlementRequestId — the sreq_ request it paid.
  • settlementHoldId — the shld_ hold whose release booked it.
  • settlementReturnId — the sret_ return whose acceptance booked the reversal, on both the reversed settlement and the reversing one. Otherwise, while the settlement is COMPLETED and not itself a reversal, the return request open on it. null in every other case, including when the only return requests were declined or cancelled.
  • reversedById — the settlement that reversed this one. It is set on every REVERSED settlement, whether the reversal was booked by accepting a return or raised by TCB.
  • reversalOfId — on a reversing settlement, the settlement it reverses.

Both parties read the same ids.

receipt is the one field the receiver reads and the sender does not. When the receiver's inbound conversion rule ran, it carries what actually landed — amount, currency and the conversionOrderId that priced it — read from the ledger, not computed from a rate. It is null on the sender's read, on a settlement kept as paid, and while a conversion has not filled.

Handle the errors

Nothing is saved on any 4xx.

StatusCodeWhat to do
400SETTLEMENT_INVALIDA malformed amount, endToEndId, remittance or idempotency key — or zero or two targets named.
400AMOUNT_PRECISION_EXCEEDEDThe amount carries more decimal places than the currency settles at (USD 2, JPY 0, BHD 3). Nothing was held. Round to details.allowedDecimals and resubmit.
400HANDLE_INVALID / HANDLE_RESERVED / HANDLE_NAMESPACE_MISMATCHThe handle does not parse, is reserved, or is not namespaced under your own.
400RESOLVE_TOKEN_INVALIDThe token is altered, older than ten minutes, or was issued to a different sender. Resolve again.
400IDEMPOTENCY_KEY_REQUIRED / IDEMPOTENCY_KEY_INVALIDSend a key, in one place, within the allowed characters and length.
400WITHDRAWAL_CAP_EXCEEDEDOver the ceiling TCB set for you in this currency. A settlement draws down the same cap a withdrawal does. details names which ceiling and what the figure would have become.
403INSUFFICIENT_SCOPEThe key lacks settlement:read, settlement:manage or settlement:execute.
404NOT_ON_NETWORKNothing that can be paid answers to that address.
404SOURCE_ACCOUNT_NOT_USABLENo account of yours with that id.
404SETTLEMENT_NOT_FOUNDNo settlement with that id that you are a party to.
409HANDLE_TAKENSomebody holds that handle, or one a human would read as the same.
409HANDLE_IMMUTABLEYour handle is already set. Changing it is a support action.
409ROOT_HANDLE_REQUIREDChoose your own handle before your sub-clients'.
409INSUFFICIENT_BALANCEThe source account does not cover it.
409SOURCE_ACCOUNT_NOT_USABLEThe source account is frozen or closed.
409IDEMPOTENCY_MISMATCHThis key already settled something else. Use a new key.
409SETTLEMENT_CONFLICTA concurrent movement on the same account. Nothing was written; retry the same request.
409SENDER_NOT_ELIGIBLEThe sending account may not settle out of its tree — verification incomplete, the sub-client is closed, or it lacks sendExternal.
409SETTLEMENT_REQUIRES_APPROVALYour organisation's approval rules would hold this settlement for approval, or block it, so it cannot be posted with an API key. See When an approval control applies to a settlement above.
422SETTLEMENT_NOT_CROSS_TREEBoth accounts are under one root. Use POST /transfers.
422SETTLEMENT_FIAT_ONLYThe network settles fiat only.
422SETTLEMENT_TARGET_UNREACHABLEThat member cannot be paid right now.
422RECEIVER_HAS_NO_ACCOUNTThey have no active account in this currency.
422RECEIVE_POLICY_REFUSEDThey accept settlements from connected senders only, and you are not on their list. Ask them to add your handle.
422CONNECTION_INVALIDYou tried to connect yourself, or a node inside your own tree.
404CONNECTION_NOT_FOUNDThat member was not on your connections list.
403SETTLEMENT_REQUEST_NOT_YOURS / SETTLEMENT_HOLD_NOT_YOURS / SETTLEMENT_RETURN_NOT_YOURSOnly the payer approves a request, only the holder releases a hold, only the receiver accepts a return.
404SETTLEMENT_REQUEST_NOT_FOUND / SETTLEMENT_HOLD_NOT_FOUND / SETTLEMENT_RETURN_NOT_FOUNDNo such object that you are a party to.
409SETTLEMENT_REQUEST_NOT_OPENIt was already approved, declined, cancelled or expired.
409SETTLEMENT_REQUEST_EXPIREDIts due-by date has passed. The row is marked EXPIRED as you are told.
409SETTLEMENT_HOLD_NOT_ACTIVEIt was already released or cancelled.
409SETTLEMENT_RETURN_NOT_OPENIt was already accepted, declined or cancelled.
409SETTLEMENT_ALREADY_REVERSEDThat settlement already has a reversal.
409SETTLEMENT_NOT_RETURNABLEIt is not completed, or it is itself a return.
422SETTLEMENT_RETURN_PARTIAL_UNSUPPORTEDOmit amount, or send the settlement amount exactly.

The full catalogue is in Errors.

Replaying an Idempotency-Key returns the original settlement and posts nothing; the key is remembered permanently, on the settlement row itself, so a replay long afterwards still resolves to that one movement.

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