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

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

FieldTypeRequiredNotes
urlstring, up to 2048 charsyesAn absolute https URL. Plain http is accepted for localhost only, outside production, for local integration tests.
eventsstring arrayyesA non-empty, duplicate-free subset of the catalogue. There is no wildcard. A subscription never widens on its own when the catalogue grows.
Request
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"]
  }'
Response 201
{
  "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.

HeaderCarries
tcb-webhook-signaturet=<unix seconds>,v1=<hex> and a second v1= entry while a rotation overlap is open.
tcb-webhook-idThe delivery id. Stable across every retry of the same delivery.
tcb-webhook-eventThe 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

Delivery 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" },
  "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.

  1. Read t from the signature header and reject it if it is older than your tolerance. Five minutes is a good default; it bounds replays.
  2. Recompute the HMAC with your secret over `${t}.${rawBody}`.
  3. Compare it, in constant time, against every v1 entry 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.

verify.js
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.

InputValue
Secretwhsec_k3vJ9aQ2mR7xL0pT4wZ8nB1yC6dF5gH2jK9sV3eA7uQ
t1788344134
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 v10797ff9b1a6ba4aa039e3d6f3a93eacd760518b89981e5f5d96577887fe63302

The header a delivery with those values would carry:

tcb-webhook-signature: t=1788344134,v1=0797ff9b1a6ba4aa039e3d6f3a93eacd760518b89981e5f5d96577887fe63302

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

Request
curl https://api.threecommabank.com/api/partner/v1/deposits/2f6c9a41-3e58-4b70-9d12-6a8f0c3e5b71 \
  -H "Authorization: Bearer $TCB_API_KEY"

Event catalogue

EventFires whenFetch
deposit.settledA deposit settled — your shares are mintedGET /deposits/{id}
deposit.failedA deposit failed terminally — nothing was chargedGET /deposits/{id}
withdrawal.settledA withdrawal settled — your fiat is creditedGET /withdrawals/{id}
withdrawal.failedA withdrawal failed terminally — nothing was withdrawnGET /withdrawals/{id}
conversion.settledBoth legs of a conversion settledGET /conversions/{id}
token_mint.settledA tcbUSD mint settled — fiat debited, tcbUSD booked to the sub-accountGET /token-mints/{id}
token_mint.failedA tcbUSD mint failed terminally — nothing was chargedGET /token-mints/{id}
token_redemption.settledA tcbUSD redemption settled — tcbUSD burned, fiat creditedGET /token-redemptions/{id}
token_redemption.failedA tcbUSD redemption failed terminally — nothing was burnedGET /token-redemptions/{id}
share_subscription.settledA vault subscription settled — shares booked to the sub-accountGET /share-subscriptions/{id}
share_subscription.failedA vault subscription failed terminally — nothing was sent to the vaultGET /share-subscriptions/{id}
share_redemption.settledAn instant vault redemption settled — tcbUSD booked to the sub-accountGET /share-redemptions/{id}
share_redemption.failedAn instant vault redemption failed terminally — nothing was redeemedGET /share-redemptions/{id}
share_redemption_request.queuedA queued redemption is in the vault's queueGET /share-redemption-requests/{id}
share_redemption_request.settledA queued redemption closed with something paid — all of it, or the part paid before you cancelledGET /share-redemption-requests/{id}
share_redemption_request.cancelledA queued redemption was cancelled before anything was paid; the shares are available againGET /share-redemption-requests/{id}
share_redemption_request.failedA queued redemption never reached the vault's queue; the shares are available againGET /share-redemption-requests/{id}
conversion.failedA conversion failed terminally — nothing movedGET /conversions/{id}
transfer.completedA within-tree transfer posted to both accountsGET /transfers/{id}
sub_client.createdYou created a sub-clientGET /sub-clients/{id}
sub_client.verification.updatedA sub-client's verification status or capabilities changedGET /sub-clients/{id}
sub_client.closedA sub-client was closedGET /sub-clients/{id}
account.createdAn account was openedGET /accounts/{id}
account.iban.issuedAn 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.createdGET /accounts/{id}
account.closedAn account was closedGET /accounts/{id}
crypto_deposit.creditedA confirmed on-chain arrival was credited to your accountGET /crypto-deposits/{id}
beneficiary.activatedA destination became usable — TCB approval granted and its holding period elapsedGET /beneficiaries/{id}
beneficiary.blockedA destination was blocked — screening matched it, or a TCB operator blocked itGET /beneficiaries/{id}
payout.settledThe rail confirmed a payout settledGET /payouts/{id}
payout.failedA payout failed terminally — nothing left your accountGET /payouts/{id}
payout.returnedThe receiving bank returned a payout; the amount was re-credited. On a payout still PROCESSING it fires together with payout.settledGET /payouts/{id}
payout.heldA payout is debited and held for review by TCB before it goes out; nothing has been sentGET /payouts/{id}
payout.releasedTCB released a held payout; it continues to PROCESSINGGET /payouts/{id}
settlement.settledA settlement posted. Both parties are notifiedGET /settlements/{id}
settlement.reversedA settlement was returned; the linked reversing settlement postedGET /settlements/{id}
settlement.request.createdA settlement request was raised — you asked, or you are being askedGET /settlement-requests/{id}
settlement.request.approvedA request was paid. Fires when the settlement POSTS, not when a colleague votesGET /settlement-requests/{id}
settlement.request.declinedA request was refusedGET /settlement-requests/{id}
settlement.hold.createdFunds were reserved against a memberGET /settlement-holds/{id}
settlement.hold.releasedA hold settled to the member it was reserved againstGET /settlement-holds/{id}
settlement.hold.cancelledA hold was cancelled and the reserve returnedGET /settlement-holds/{id}
settlement.return.requestedThe sender asked for a settlement to be returnedGET /settlement-returns/{id}
settlement.return.acceptedA return was accepted; the reversing settlement postedGET /settlement-returns/{id}
settlement.return.declinedA return was refused; the original standsGET /settlement-returns/{id}
settlement.netting.agreement.proposedA member proposed to net with you, or you proposedGET /netting/agreements/{id}
settlement.netting.agreement.acceptedThe agreement is ACTIVE; obligations may be owed into itGET /netting/agreements/{id}
settlement.netting.agreement.declinedThe proposal was refusedGET /netting/agreements/{id}
settlement.netting.agreement.endedThe agreement was ended by one partyGET /netting/agreements/{id}
settlement.netting.obligation.createdAn amount was owed into the set and reservedGET /netting/obligations/{id}
settlement.netting.obligation.withdrawnAn OPEN obligation was withdrawn; its reserve releasedGET /netting/obligations/{id}
settlement.netting.cutThe set was cut. Fires with the net settlement's settlement.settled, or alone when the net was zeroGET /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.

AttemptSent
1Straight away
21 minute after attempt 1 failed
35 minutes later
430 minutes later
52 hours later
68 hours later
724 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.

Request
curl -X POST https://api.threecommabank.com/api/partner/v1/webhooks/4e8b1d70-a35c-4692-b0f1-9d2c6a4e73b8/rotate-secret \
  -H "Authorization: Bearer $TCB_API_KEY"
Response 200
{
  "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

CallScopeDoes
GET /webhookswebhook:manageLists your subscriptions, oldest first. Secrets appear as secretPreview only.
GET /webhooks/{id}webhook:manageOne subscription, same shape.
DELETE /webhooks/{id}webhook:manageSets 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.settled can reach you before an earlier deposit.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 v1 entry.
  • Subscribe to the failure events too. deposit.failed, withdrawal.failed and conversion.failed are the only events that tell you a flow ended without money moving.

Handle the errors

StatusCodeWhat to do
400WEBHOOK_URL_INVALIDThe URL was not absolute, or not https.
400WEBHOOK_EVENTS_INVALIDThe list was empty, held a duplicate, or named an event outside the catalogue. details.catalogue lists the valid names.
403INSUFFICIENT_SCOPEThe key lacks webhook:manage.
404WEBHOOK_SUBSCRIPTION_NOT_FOUNDNo 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.

Pilot disclosure

On this page