Webhooks
Webhooks push a signed HTTP POST to your server as your payment orders reach the key lifecycle states in the
event catalog — FUNDED, DISTRIBUTED, REFUNDED, and the rest — so you can react without polling
GET /payment-orders. You register one or more endpoint URLs, TBMC signs every delivery with a secret only you hold,
and delivery is durable: failed sends are retried with backoff for up to roughly half a day.
This guide covers registering an endpoint, the event envelope and catalog, verifying signatures, retries, delivery semantics, and rotating your signing secret. For the full request/response schema of every endpoint, see the API Reference under Webhooks.
How it works
-
Register an endpoint — give TBMC an HTTPS URL and the event types to subscribe to. You receive a signing
secret(whsec_…) once. -
Receive signed POSTs — when a subscribed payment order reaches one of the states in the catalog below (or one of
its deposits changes status), TBMC sends a JSON envelope to your URL with an
X-TBMC-Webhook-Signatureheader. - Verify, process, and acknowledge — check the signature against your secret, handle the event, and return a
2xx.
Delivery runs on the same durable infrastructure as the rest of the clearinghouse: each webhook is an independent, retried job. You can inspect every attempt in the delivery log and replay any delivery.
HTTPS only. Endpoint URLs must be
https://, and TBMC blocks private, loopback, and cloud-metadata addresses. A URL that doesn't resolve to a public host is rejected at registration.
Register an endpoint
POST /webhooks/endpoints. The response includes the signing secret — this is the only time it is ever returned,
so store it somewhere your webhook handler can read it (a secret manager, not source control).
curl -X POST https://api.bettermoney.com/api/v1/webhooks/endpoints \
-H "x-api-key: $TBMC_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.your-app.com/tbmc/webhooks",
"description": "Production payment-order events",
"eventTypes": ["payment_order"]
}'
const res = await fetch('https://api.bettermoney.com/api/v1/webhooks/endpoints', {
method: 'POST',
headers: {
'x-api-key': process.env.TBMC_API_KEY!,
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: 'https://api.your-app.com/tbmc/webhooks',
description: 'Production payment-order events',
eventTypes: ['payment_order'],
}),
});
const { id, secret } = await res.json();
// Persist `secret` now — it is never returned again.
{
"id": "b7e2c1a0-5f3d-4c8a-9e21-7a6b5c4d3e2f",
"accountId": "550e8400-e29b-41d4-a716-446655440000",
"url": "https://api.your-app.com/tbmc/webhooks",
"description": "Production payment-order events",
"status": "ENABLED",
"eventTypes": ["payment_order"],
"consecutiveFailures": 0,
"createdAt": "2026-07-01T15:00:00.000Z",
"updatedAt": "2026-07-01T15:00:00.000Z",
"disabledAt": null,
"deletedAt": null,
"secret": "whsec_9d4e2f7a1b3c5d6e8f09a1b2c3d4e5f607182a3b4c5d6e7f8091a2b3c4d5e6f7"
}
Manage endpoints with GET /webhooks/endpoints (list — secrets are never returned), GET /webhooks/endpoints/{id},
PATCH /webhooks/endpoints/{id} (change the URL, description, or flip status between ENABLED and DISABLED), and
DELETE /webhooks/endpoints/{id} (soft-delete — the endpoint stops receiving events but its delivery history is kept).
The event envelope
Every delivery — real or test — has the same top-level shape. The entity is nested under a key named for its type
(data.payment_order), so the payload is self-describing, and data.payment_order is the full payment order — the
same object the REST GET /payment-orders/{id} endpoint returns (see the API reference), including its
current deposits under inboundTransfers:
{
"id": "evt_918273_payment_order",
"type": "payment_order",
"created": "2026-07-01T15:04:05.123Z",
"accountId": "550e8400-e29b-41d4-a716-446655440000",
"data": {
"payment_order": {
"id": "9f2c7b1a-4d3e-4a8b-bc21-1e2f3a4b5c6d",
"status": "FUNDED",
"version": 4,
"mode": "netted",
"amountUsd": 25.0,
"asset": "USDC",
"toChain": "base",
"toAddress": "0xrecipient…",
"memo": "invoice-4821",
"createdAt": "2026-07-01T15:00:00.000Z",
"expiresAt": "2026-07-01T16:00:00.000Z",
"receivedTotal": 25.0,
"inboundTransfers": [
{
"transactionHash": "0xabc123…",
"status": "CONFIRMED",
"version": 2,
"amountUsd": 25.0,
"asset": "USDC",
"chain": "base",
"sender": "0xsender…",
"observedAt": "2026-07-01T15:03:00.000Z"
}
]
}
}
}
| Field | Type | Notes |
|---|---|---|
id |
string | The stable logical-event id, evt_<eventId>_<type>. Identical across retries and replays — dedupe on it. |
type |
string | The public event type. Always payment_order today. |
created |
string | ISO 8601 timestamp of when the event was recorded. Use it to order events (see Delivery semantics). |
accountId |
string | Your account id (UUID). |
data |
object | The entity, nested under its type key — data.payment_order here (the full payment order). |
data.payment_order is the same object shape the REST GET /payment-orders/{id} endpoint returns — see the
API reference for the full field list. The lifecycle state travels in data.payment_order.status, and the
envelope id is also sent in the X-TBMC-Webhook-Id header, so you can dedupe without parsing the body.
The payload is the order as of this event — not guaranteed to be its latest state. Deliveries are at-least-once and can arrive out of order (see Delivery semantics), so a later event may already have moved the order on. When you need the current authoritative state, re-fetch
GET /payment-orders/{id}.
Inbound transfers
data.payment_order.inboundTransfers is the order's current set of deposits (the inbound transfers that fund it) —
the same collection the order carries in the REST API. A payment_order webhook fires on every deposit status change,
not only the order-level milestones: a deposit being observed, confirmed on-chain, verified, or returned each fires one.
These can arrive while the order itself stays AWAITING_FUNDS — a partial funding, where one deposit confirms but the
received total is still short of the order amount, is delivered this way.
Each deposit's status is a coarse, deposit-level status (distinct from the order-level status):
Transfer status |
Meaning |
|---|---|
PENDING_VERIFICATION |
Deposit observed; awaiting on-chain confirmation. |
ONCHAIN_CONFIRMED |
Confirmed on-chain and in TBMC custody; not yet swept to clearing. |
CONFIRMED |
Swept into the clearing account — counts toward funding the order. |
RETURNING |
Being returned to the sender. |
RETURNED |
Returned to the sender. |
FAILED |
Verification, sweep, or return failed — needs attention. |
Once a deposit enters the return flow, its entry also carries refundReference and — after the return settles on-chain
— refundTransactionHash.
Event catalog
There is a single public webhook type — payment_order — and the lifecycle state travels in
data.payment_order.status. Subscribe to it by passing "eventTypes": ["payment_order"] when you register. Every
delivery carries the full payment order object (see the API reference for every field); the table below is
the complete set of statuses that fire a webhook.
A payment_order webhook fires as the order enters each of the states below. Intermediate, in-flight states
(LOCKED, PENDING_NET_SETTLEMENT, READY_TO_DISTRIBUTE, and the *_PENDING / *_SUBMITTED steps) do not each
emit their own webhook — you receive the next milestone in the table instead, so don't wait on a notification for a
state that isn't listed here.
status |
Fires when |
|---|---|
AWAITING_FUNDS |
The order is created and is waiting to be funded. |
FUNDED |
The inbound transfer has been observed and is sufficient. |
DISTRIBUTED |
Terminal (clearing). Crypto has been delivered to the recipient. |
DISTRIBUTION_FAILED |
Distribution could not complete after retries — needs attention. |
EXPIRED |
The order expired. (REMEDIATING_EXPIRED if it had partial funds to return.) |
CANCELED |
Terminal. The order was canceled before funding completed. |
REFUNDED |
Funds were returned to the sender. Reopenable — a late deposit can re-refund. |
Correlating a state change to its on-platform attempt. The order carries
distributionReference; a returned deposit carriesinboundTransfers[].refundReferenceand, once the return lands,inboundTransfers[].refundTransactionHash. Because each retry gets a fresh reference, these let you tie a state change to the specific attempt behind it — e.g. aDISTRIBUTEDto a laterDISTRIBUTION_FAILEDretry.
New status values may be added over time as the platform grows. Treat an unrecognized status as informational rather
than erroring — key your logic off the statuses you care about.
Verify signatures
Every delivery carries an X-TBMC-Webhook-Signature header. Always verify it before trusting a payload — it proves
the request came from TBMC and wasn't tampered with.
The header format is:
X-TBMC-Webhook-Signature: t=1719849845,v1=<hex>[,v1=<hex>]
t— the Unix timestamp (seconds) when the delivery was signed.-
v1— one or more hex-encoded HMAC-SHA256 signatures. There is normally one; during a graceful secret rotation there are two (old + new). Accept the request if anyv1matches.
To verify, recompute the signature and compare:
- Take the exact raw request body (the bytes as received — do not re-serialize the parsed JSON).
- Build the signed string
`${t}.${rawBody}`— the timestamp, a literal., then the raw body. -
Compute
HMAC-SHA256of that string, using yourwhsec_…secret as a raw UTF-8 string for the key (do not base64-decode it). Hex-encode the result. - Compare (in constant time) against each
v1value. Accept on the first match. - Reject the request if
tis more than 5 minutes from your current time — this bounds replay attacks.
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SEC = 300; // reject signatures older/newer than 5 minutes
/** Verify a TBMC webhook. `rawBody` MUST be the exact received bytes, before JSON.parse. */
export function verifyTbmcWebhook(rawBody: string, signatureHeader: string, secret: string): boolean {
const parts = signatureHeader.split(',').map((p) => p.trim());
const t = parts.find((p) => p.startsWith('t='))?.slice(2);
const v1s = parts.filter((p) => p.startsWith('v1=')).map((p) => p.slice(3));
if (!t || v1s.length === 0) return false;
// Replay protection: reject stale timestamps.
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(t));
if (!Number.isFinite(age) || age > TOLERANCE_SEC) return false;
// HMAC-SHA256 over `${t}.${rawBody}`, secret used as a raw UTF-8 key.
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const expectedBuf = Buffer.from(expected, 'hex');
// Accept if ANY provided signature matches (two are present mid-rotation).
return v1s.some((v1) => {
const givenBuf = Buffer.from(v1, 'hex');
return givenBuf.length === expectedBuf.length && timingSafeEqual(givenBuf, expectedBuf);
});
}
import express from 'express';
const app = express();
// Capture the RAW body — you must verify over the exact received bytes.
app.post('/tbmc/webhooks', express.raw({ type: 'application/json' }), async (req, res) => {
const rawBody = (req.body as Buffer).toString('utf8');
const signature = req.header('X-TBMC-Webhook-Signature') ?? '';
if (!verifyTbmcWebhook(rawBody, signature, process.env.TBMC_WEBHOOK_SECRET!)) {
return res.status(400).send('invalid signature');
}
const event = JSON.parse(rawBody);
const webhookId = req.header('X-TBMC-Webhook-Id'); // stable dedupe key
// if (await alreadyProcessed(webhookId)) return res.sendStatus(200);
// Branch on the event type. `webhook.ping` (test sends) carries `data.message`, not
// `data.payment_order`, so only the `payment_order` type has an order to process. Ignoring
// unrecognized types also keeps you forward-compatible if new types are added later.
if (event.type === 'payment_order') {
await handlePaymentOrder(event.data.payment_order); // { id, status, ... }
}
// Acknowledge last — return 2xx once the event is handled. Each attempt has a 10-second timeout,
// which comfortably covers typical handling; if yours might exceed it, enqueue the event and ack
// right away instead (see Responding to webhooks).
res.sendStatus(200);
});
Request headers
| Header | Value |
|---|---|
X-TBMC-Webhook-Signature |
t=<unix>,v1=<hex>[,v1=<hex>] — verify as above. |
X-TBMC-Webhook-Id |
The envelope id (evt_<eventId>_<type>) — stable across retries and replays; dedupe on it. |
X-TBMC-Webhook-Delivery-Id |
A UUID for this individual delivery. Differs per delivery record; not a dedupe key. |
X-TBMC-Event |
The event type (payment_order; webhook.ping for a test send). |
Content-Type |
application/json |
Responding to webhooks
Return any 2xx status to acknowledge a delivery. Anything else (or a timeout) is treated as a failure and retried.
-
Return
2xxonce you've handled the event. Each attempt has a 10-second timeout, which comfortably covers typical handling. If your processing might exceed it, enqueue the event and return2xxright away, then work it off the queue — don't leave TBMC waiting on slow work inside the request. -
Be idempotent. Delivery is at-least-once, so the same event can arrive more than once. Dedupe on
X-TBMC-Webhook-Id(see below). -
Return non-2xx to trigger a retry. If you can't process an event right now, returning a
5xx(or timing out) puts it back on the retry schedule.
Retries and auto-disable
If a delivery doesn't get a 2xx, TBMC retries it with exponential backoff:
- ~60 attempts over roughly half a day.
- Backoff starts at 10 seconds and doubles each attempt, capped at 15 minutes between attempts.
- Each attempt has a 10-second send timeout.
After the attempts are exhausted, the delivery is marked FAILED and dead-lettered — it stays in your delivery log for
inspection and manual replay.
Endpoints auto-disable after sustained failure. If an endpoint accumulates 10 consecutive failed deliveries, TBMC disables it (
status: DISABLED) to cap the blast radius, and alerts us. A single successful delivery resets the counter. Re-enable a disabled endpoint withPATCH /webhooks/endpoints/{id}once you've fixed the receiver.
Delivery semantics
-
At-least-once. A
2xxcan be lost (a network blip after your server processed the event, a redelivery during infrastructure failover), so the same event may arrive more than once. Dedupe onX-TBMC-Webhook-Id, which is stable for a given event across every retry and manual replay. -
No ordering guarantee. Events for one order can arrive out of order, and events for different orders are delivered
concurrently. Don't assume
FUNDEDarrives beforeDISTRIBUTED. Each payload carries a monotonically increasingdata.payment_order.version(and each deposit its owninboundTransfers[].version); compare it against the highest you've applied for that same order or deposit to drop stale or reordered deliveries. When it matters, reconcile against the authoritative order state fromGET /payment-orders/{id}— the API is always the source of truth; the webhook is a prompt to go read it.
Rotate your signing secret
POST /webhooks/endpoints/{id}/rotate-secret mints a new secret (returned once, in the secretNext field). There are
two modes:
Graceful (default). The endpoint signs each delivery with both the old and the new secret — two v1= entries —
so you can roll your verifier over with zero downtime:
- Call rotate-secret; store the new secret.
-
Deploy your verifier with the new secret. (Because you check every
v1, it keeps accepting deliveries throughout — this is exactly why the verification step above loops over allv1values.) -
Finalize with
POST /webhooks/endpoints/{id}/rotate-secret/promote— this retires the old secret, and deliveries are then signed with only the new one.
Immediate (suspected compromise). Pass ?immediate=true. The old secret is retired at once — deliveries sign
with only the new secret, so update your verifier immediately or signature checks will fail.
# 1. Mint the new secret (endpoint now signs with old + new).
curl -X POST https://api.bettermoney.com/api/v1/webhooks/endpoints/$ID/rotate-secret \
-H "x-api-key: $TBMC_API_KEY"
# → { "id": "...", "secretNext": "whsec_..." }
# 2. Deploy your verifier with the new secret, then finalize:
curl -X POST https://api.bettermoney.com/api/v1/webhooks/endpoints/$ID/rotate-secret/promote \
-H "x-api-key: $TBMC_API_KEY"
# Old secret dies immediately — only the new secret signs from now on.
curl -X POST "https://api.bettermoney.com/api/v1/webhooks/endpoints/$ID/rotate-secret?immediate=true" \
-H "x-api-key: $TBMC_API_KEY"
# → { "id": "...", "secretNext": "whsec_..." }
Delivery log and replay
Every delivery and each of its attempts is recorded, so you can debug failures without guessing:
-
GET /webhooks/deliveries— list deliveries (newest first), filterable by endpoint, status, or event type, paginated withlimit/offset. -
GET /webhooks/deliveries/{id}— the signed request envelope plus the full per-attempt response history (status codes, response snippets, timings). -
POST /webhooks/deliveries/{id}/replay— re-queue a delivery to send again. The same event payload is sent (soX-TBMC-Webhook-Idis unchanged — your dedupe still holds); a new delivery record is created for the replay.
Test your endpoint
POST /webhooks/endpoints/{id}/test signs and sends a synthetic event to your URL synchronously and reports whether
your server returned 2xx. It's a connectivity + signature check — it does not create a delivery-log row.
The test payload carries the reserved type webhook.ping (never a real payment-order event, so it can't be mistaken
for one):
{
"id": "evt_test_5f3d4c8a-9e21-7a6b-5c4d-3e2f1a0b9c8d",
"type": "webhook.ping",
"created": "2026-07-01T15:10:00.000Z",
"accountId": "550e8400-e29b-41d4-a716-446655440000",
"data": { "message": "This is a test event from TBMC webhooks. No action is required." }
}
Verify the
X-TBMC-Webhook-Signatureon the test exactly as you would a real event, and return2xx. Don't gate acceptance on the event-type enum — allowwebhook.pingthrough so this connectivity check succeeds.
What's next
- Browse the API Reference under Webhooks for the full request/response schema of every endpoint.
- New to the API? Start with Getting Started to create and fund your first payment order.