Webhooks

Webhooks push a signed HTTP POST to your server as your payment orders reach the key lifecycle states in the event catalogFUNDED, 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

  1. Register an endpoint — give TBMC an HTTPS URL and the event types to subscribe to. You receive a signing secret (whsec_…) once.
  2. 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-Signature header.
  3. 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 secretthis 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 carries inboundTransfers[].refundReference and, 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. a DISTRIBUTED to a later DISTRIBUTION_FAILED retry.

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 any v1 matches.

To verify, recompute the signature and compare:

  1. Take the exact raw request body (the bytes as received — do not re-serialize the parsed JSON).
  2. Build the signed string `${t}.${rawBody}` — the timestamp, a literal ., then the raw body.
  3. Compute HMAC-SHA256 of that string, using your whsec_… secret as a raw UTF-8 string for the key (do not base64-decode it). Hex-encode the result.
  4. Compare (in constant time) against each v1 value. Accept on the first match.
  5. Reject the request if t is 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 2xx once 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 return 2xx right 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 with PATCH /webhooks/endpoints/{id} once you've fixed the receiver.

Delivery semantics

  • At-least-once. A 2xx can 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 on X-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 FUNDED arrives before DISTRIBUTED. Each payload carries a monotonically increasing data.payment_order.version (and each deposit its own inboundTransfers[].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 from GET /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:

  1. Call rotate-secret; store the new secret.
  2. 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 all v1 values.)
  3. 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 with limit/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 (so X-TBMC-Webhook-Id is 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-Signature on the test exactly as you would a real event, and return 2xx. Don't gate acceptance on the event-type enum — allow webhook.ping through 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.