Webhooks
Register a signed endpoint, verify what arrives, and fetch the object the event points at.
Concepts: Which webhook fires when
TCB signs and POSTs an event to your endpoint when something terminal happens to your money or your registry, so you do not have to poll. Every route on this page needs scope webhook:manage and acts as you: they refuse x-on-behalf-of with 400 ON_BEHALF_OF_NOT_ALLOWED_HERE. A sub-client holds no subscription of its own, so an event about one of your sub-clients arrives on your endpoint.
Register an endpoint
POST /webhooks · scope webhook:manage
| Field | Type | Required | Notes |
|---|---|---|---|
url | string, up to 2048 chars | yes | An absolute https URL. Plain http is accepted for localhost only, outside production, for local integration tests. |
events | string array | yes | A non-empty, duplicate-free subset of the catalogue. There is no wildcard. A subscription never widens on its own when the catalogue grows. |
curl -X POST https://api.threecommabank.com/api/partner/v1/webhooks \
-H "Authorization: Bearer $TCB_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://partner.example.com/tcb/webhooks",
"events": ["deposit.settled", "withdrawal.settled", "transfer.completed"]
}'{
"success": true,
"data": {
"webhookId": "4e8b1d70-a35c-4692-b0f1-9d2c6a4e73b8",
"url": "https://partner.example.com/tcb/webhooks",
"events": ["deposit.settled", "withdrawal.settled", "transfer.completed"],
"status": "ACTIVE",
"secret": "whsec_k3vJ9aQ2mR7xL0pT4wZ8nB1yC6dF5gH2jK9sV3eA7uQ",
"secretPreview": "whsec_k3vJ9aQ2…",
"previousSecretExpiresAt": null,
"createdAt": "2026-09-02T10:15:28.000Z",
"disclosure": { "version": "2026-09-06", "summary": "…", "items": ["…"] }
},
"timestamp": "2026-09-02T10:15:28.377Z"
}secret is present only on the response that mints it: this call and a rotation. Store it immediately. Reads return secretPreview instead, which is enough to tell which secret you hold. The disclosure object is the one printed at the top of this page, abbreviated in the example.
Receive a delivery
A delivery is a POST with content-type: application/json and three headers. Header names are case-insensitive.
| Header | Carries |
|---|---|
tcb-webhook-signature | t=<unix seconds>,v1=<hex> and a second v1= entry while a rotation overlap is open. |
tcb-webhook-id | The delivery id. Stable across every retry of the same delivery. |
tcb-webhook-event | The event type, so you can route before parsing. |
The body is thin: an identifier, a type, a time, a pointer and the disclosure. It never carries a snapshot of the object.
Prop
Type
{
"id": "9c1e5b3a-7d24-4f80-b6a1-2e5c9017a4f6",
"type": "deposit.settled",
"occurredAt": "2026-09-02T10:15:34.000Z",
"resource": { "type": "deposit", "id": "2f6c9a41-3e58-4b70-9d12-6a8f0c3e5b71" },
"disclosure": { "version": "2026-09-06", "summary": "…", "items": ["…"] }
}A payout.* event for a payment you made on behalf of a sub-client adds resource.onBehalfOf — the sub-client id to send back on the fetch. It is absent on every event for your own money.
Answer 2xx quickly, before you do your own processing. Four things count as a failed attempt: a non-2xx status, a connection error, a hang past 10 seconds, and a redirect. Deliveries never follow a redirect.
Verify the signature
Each v1 entry is HMAC_SHA256(secret, t + "." + rawBody), hex-encoded. The signed string is the timestamp from the header, a single ., then the raw request body byte for byte. Parse the body only after the check passes, and never re-serialise it first.
- Read
tfrom the signature header and reject it if it is older than your tolerance. Five minutes is a good default; it bounds replays. - Recompute the HMAC with your secret over
`${t}.${rawBody}`. - Compare it, in constant time, against every
v1entry in the header. A match on any one verifies.
Compare against every entry, because a rotation overlap puts two v1 entries on the line, one per valid secret.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyTcbWebhook(header, rawBody, secret, toleranceSeconds = 300) {
const parts = header.split(',').map((p) => p.trim());
const t = parts.find((p) => p.startsWith('t='))?.slice(2);
if (!t || !/^\d+$/.test(t)) return false;
if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false;
const expected = Buffer.from(
createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex'),
'utf8',
);
return parts
.filter((p) => p.startsWith('v1='))
.some((p) => {
const candidate = Buffer.from(p.slice(3), 'utf8');
return (
candidate.length === expected.length && timingSafeEqual(candidate, expected)
);
});
}Check your implementation against this vector
Feed these exact values to your verifier. It is a fixed test input, not a real delivery, so the body is shorter than one you will receive.
| Input | Value |
|---|---|
| Secret | whsec_k3vJ9aQ2mR7xL0pT4wZ8nB1yC6dF5gH2jK9sV3eA7uQ |
t | 1788344134 |
| Raw body | {"id":"9c1e5b3a-7d24-4f80-b6a1-2e5c9017a4f6","type":"deposit.settled","occurredAt":"2026-09-02T10:15:34.000Z","resource":{"type":"deposit","id":"2f6c9a41-3e58-4b70-9d12-6a8f0c3e5b71"}} |
Expected v1 | 0797ff9b1a6ba4aa039e3d6f3a93eacd760518b89981e5f5d96577887fe63302 |
The header a delivery with those values would carry:
tcb-webhook-signature: t=1788344134,v1=0797ff9b1a6ba4aa039e3d6f3a93eacd760518b89981e5f5d96577887fe63302That timestamp is far outside any sensible tolerance, so disable the staleness check while running the vector and re-enable it afterwards.
Fetch the object
An event tells you that something changed. It does not carry the object's current state. Fetch the resource it points at, using scope transaction:read for the money objects and the matching read scope otherwise, then act on the response.
curl https://api.threecommabank.com/api/partner/v1/deposits/2f6c9a41-3e58-4b70-9d12-6a8f0c3e5b71 \
-H "Authorization: Bearer $TCB_API_KEY"Event catalogue
| Event | Fires when | Fetch |
|---|---|---|
deposit.settled | A deposit settled — your shares are minted | GET /deposits/{id} |
deposit.failed | A deposit failed terminally — nothing was charged | GET /deposits/{id} |
withdrawal.settled | A withdrawal settled — your fiat is credited | GET /withdrawals/{id} |
withdrawal.failed | A withdrawal failed terminally — nothing was withdrawn | GET /withdrawals/{id} |
conversion.settled | Both legs of a conversion settled | GET /conversions/{id} |
token_mint.settled | A tcbUSD mint settled — fiat debited, tcbUSD booked to the sub-account | GET /token-mints/{id} |
token_mint.failed | A tcbUSD mint failed terminally — nothing was charged | GET /token-mints/{id} |
token_redemption.settled | A tcbUSD redemption settled — tcbUSD burned, fiat credited | GET /token-redemptions/{id} |
token_redemption.failed | A tcbUSD redemption failed terminally — nothing was burned | GET /token-redemptions/{id} |
share_subscription.settled | A vault subscription settled — shares booked to the sub-account | GET /share-subscriptions/{id} |
share_subscription.failed | A vault subscription failed terminally — nothing was sent to the vault | GET /share-subscriptions/{id} |
share_redemption.settled | An instant vault redemption settled — tcbUSD booked to the sub-account | GET /share-redemptions/{id} |
share_redemption.failed | An instant vault redemption failed terminally — nothing was redeemed | GET /share-redemptions/{id} |
share_redemption_request.queued | A queued redemption is in the vault's queue | GET /share-redemption-requests/{id} |
share_redemption_request.settled | A queued redemption closed with something paid — all of it, or the part paid before you cancelled | GET /share-redemption-requests/{id} |
share_redemption_request.cancelled | A queued redemption was cancelled before anything was paid; the shares are available again | GET /share-redemption-requests/{id} |
share_redemption_request.failed | A queued redemption never reached the vault's queue; the shares are available again | GET /share-redemption-requests/{id} |
conversion.failed | A conversion failed terminally — nothing moved | GET /conversions/{id} |
transfer.completed | A within-tree transfer posted to both accounts | GET /transfers/{id} |
sub_client.created | You created a sub-client | GET /sub-clients/{id} |
sub_client.verification.updated | A sub-client's verification status or capabilities changed | GET /sub-clients/{id} |
sub_client.closed | A sub-client was closed | GET /sub-clients/{id} |
account.created | An account was opened | GET /accounts/{id} |
account.iban.issued | An IBAN was issued: sent once per IBAN, for the first account that shows it. A currency added later on the same IBAN sends only account.created | GET /accounts/{id} |
account.closed | An account was closed | GET /accounts/{id} |
crypto_deposit.credited | A confirmed on-chain arrival was credited to your account | GET /crypto-deposits/{id} |
beneficiary.activated | A destination became usable — TCB approval granted and its holding period elapsed | GET /beneficiaries/{id} |
beneficiary.blocked | A destination was blocked — screening matched it, or a TCB operator blocked it | GET /beneficiaries/{id} |
payout.settled | The rail confirmed a payout settled | GET /payouts/{id} |
payout.failed | A payout failed terminally — nothing left your account | GET /payouts/{id} |
payout.returned | The receiving bank returned a payout; the amount was re-credited. On a payout still PROCESSING it fires together with payout.settled | GET /payouts/{id} |
payout.held | A payout is debited and held for review by TCB before it goes out; nothing has been sent | GET /payouts/{id} |
payout.released | TCB released a held payout; it continues to PROCESSING | GET /payouts/{id} |
settlement.settled | A settlement posted. Both parties are notified | GET /settlements/{id} |
settlement.reversed | A settlement was returned; the linked reversing settlement posted | GET /settlements/{id} |
settlement.request.created | A settlement request was raised — you asked, or you are being asked | GET /settlement-requests/{id} |
settlement.request.approved | A request was paid. Fires when the settlement POSTS, not when a colleague votes | GET /settlement-requests/{id} |
settlement.request.declined | A request was refused | GET /settlement-requests/{id} |
settlement.hold.created | Funds were reserved against a member | GET /settlement-holds/{id} |
settlement.hold.released | A hold settled to the member it was reserved against | GET /settlement-holds/{id} |
settlement.hold.cancelled | A hold was cancelled and the reserve returned | GET /settlement-holds/{id} |
settlement.return.requested | The sender asked for a settlement to be returned | GET /settlement-returns/{id} |
settlement.return.accepted | A return was accepted; the reversing settlement posted | GET /settlement-returns/{id} |
settlement.return.declined | A return was refused; the original stands | GET /settlement-returns/{id} |
settlement.netting.agreement.proposed | A member proposed to net with you, or you proposed | GET /netting/agreements/{id} |
settlement.netting.agreement.accepted | The agreement is ACTIVE; obligations may be owed into it | GET /netting/agreements/{id} |
settlement.netting.agreement.declined | The proposal was refused | GET /netting/agreements/{id} |
settlement.netting.agreement.ended | The agreement was ended by one party | GET /netting/agreements/{id} |
settlement.netting.obligation.created | An amount was owed into the set and reserved | GET /netting/obligations/{id} |
settlement.netting.obligation.withdrawn | An OPEN obligation was withdrawn; its reserve released | GET /netting/obligations/{id} |
settlement.netting.cut | The set was cut. Fires with the net settlement's settlement.settled, or alone when the net was zero | GET /netting/agreements/{agreementId}/cuts |
The step-by-step chain uses five resource types: token_mint, token_redemption, share_subscription, share_redemption and share_redemption_request. Fetch each at the path in the table — /token-mints/{id}, /token-redemptions/{id}, /share-subscriptions/{id}, /share-redemptions/{id} and /share-redemption-requests/{id}. The paths use hyphens and the types use underscores, so map them rather than building the path from the type. A part the vault pays on a queued redemption fires no event of its own: the request's queue.fills lists each one, and share_redemption_request.settled fires when the request closes.
Two absences are contractual: a compensable failure fires no event, and there is no transfer.failed. Both are explained under which webhook fires when.
Three more absences are deliberate on CommaNet. There is no settlement.failed, for the same reason there is no transfer.failed. There is no settlement.hold.expired, because a hold has no clock — it ends because somebody ended it. And there is no event for a request or a return that its own author cancelled: withdrawing your own ask tells the other side nothing it needs to act on.
CommaNet events reach both parties where both hold a subscription, so a settlement you received arrives on your registry as settlement.settled with direction: "IN" on the fetched row.
Retry ladder
An attempt fails on a non-2xx status, a connection error, a redirect, or a response that has not arrived within 10 seconds. TCB then retries on a fixed ladder. Every step follows a real failed attempt.
| Attempt | Sent |
|---|---|
| 1 | Straight away |
| 2 | 1 minute after attempt 1 failed |
| 3 | 5 minutes later |
| 4 | 30 minutes later |
| 5 | 2 hours later |
| 6 | 8 hours later |
| 7 | 24 hours later |
Seven attempts in all. When the ladder runs out, the delivery is parked as exhausted; ask TCB to replay it. A disabled subscription attempts nothing and consumes no attempts, so re-enabling it resumes what was owed. Signatures are computed at attempt time, so a retry after a rotation is signed with the secret set in force at that moment.
Rotate the signing secret
POST /webhooks/{id}/rotate-secret · scope webhook:manage
Mints a new secret, returns it in full exactly once, and sets previousSecretExpiresAt 24 hours out. Until that moment every delivery is signed with both secrets, as two v1= entries, so whichever secret your servers hold verifies. Cut over at your own pace. After that moment only the new secret signs.
curl -X POST https://api.threecommabank.com/api/partner/v1/webhooks/4e8b1d70-a35c-4692-b0f1-9d2c6a4e73b8/rotate-secret \
-H "Authorization: Bearer $TCB_API_KEY"{
"success": true,
"data": {
"webhookId": "4e8b1d70-a35c-4692-b0f1-9d2c6a4e73b8",
"url": "https://partner.example.com/tcb/webhooks",
"events": ["deposit.settled", "withdrawal.settled", "transfer.completed"],
"status": "ACTIVE",
"secret": "whsec_R8pW2nY6cH0jD4tK7bQ1xF5mZ3vA9sL2gN6eU0iT4rB",
"secretPreview": "whsec_R8pW2nY6…",
"previousSecretExpiresAt": "2026-09-03T10:15:30.000Z",
"createdAt": "2026-09-02T10:15:28.000Z",
"disclosure": { "version": "2026-09-06", "summary": "…", "items": ["…"] }
},
"timestamp": "2026-09-02T10:15:30.221Z"
}Rotating again inside an open window replaces the previous secret immediately. At most two secrets are ever valid, and the response always states the current window. Keep verifying against both until previousSecretExpiresAt has passed.
Manage your endpoints
| Call | Scope | Does |
|---|---|---|
GET /webhooks | webhook:manage | Lists your subscriptions, oldest first. Secrets appear as secretPreview only. |
GET /webhooks/{id} | webhook:manage | One subscription, same shape. |
DELETE /webhooks/{id} | webhook:manage | Sets status: "DISABLED" and stops delivery. The subscription and its delivery history are kept as the record of what was sent where. |
An id that is not yours answers 404 WEBHOOK_SUBSCRIPTION_NOT_FOUND. Another principal's subscriptions do not exist as far as this API is concerned.
Best practices
- Answer 2xx first, process second. Queue the event id and return. Your handler has 10 seconds.
- Dedupe on the event
id. Delivery is at-least-once, and the same event can arrive more than once. - Assume no ordering. A
withdrawal.settledcan reach you before an earlierdeposit.settled. Order your own work by the object you fetch, not by arrival. - Fetch the resource; do not trust the payload for state. The body carries a pointer, not a snapshot. Act on the object as it stands when you read it.
- Verify before parsing, over the raw bytes, in constant time, against every
v1entry. - Subscribe to the failure events too.
deposit.failed,withdrawal.failedandconversion.failedare the only events that tell you a flow ended without money moving.
Handle the errors
| Status | Code | What to do |
|---|---|---|
| 400 | WEBHOOK_URL_INVALID | The URL was not absolute, or not https. |
| 400 | WEBHOOK_EVENTS_INVALID | The list was empty, held a duplicate, or named an event outside the catalogue. details.catalogue lists the valid names. |
| 403 | INSUFFICIENT_SCOPE | The key lacks webhook:manage. |
| 404 | WEBHOOK_SUBSCRIPTION_NOT_FOUND | No subscription with that id in your scope. |
The full catalogue is in Errors.
The machine-readable specification for these endpoints is openapi.partner.v1.json. Where it and this page disagree, the specification is authoritative.