Webhooks & events
There are two kinds of webhook you can set up, and you need only the one that fits how you run:
| Webhook | Receives | Set up on |
|---|---|---|
| Merchant webhook | One Merchant's trade, offer, listing and checkout events | merchants.tbbnetwork.com → Webhooks, or POST /v1/webhooks/subscriptions |
| Business webhook | A Business's Space booking events — and, with includeMerchantEvents, every one of its Merchants' events | business.tbbnetwork.com → your Business → Webhooks, or POST /v1/webhooks/business-subscriptions |
If you run more than one Merchant, or Merchants and Spaces, use one Business webhook with
includeMerchantEvents: true. It receives every Merchant's events, including Merchants you link
later, at one address with one signing secret — change its address once and it changes for all of
them. A Merchant webhook is for a Merchant on its own, or a system that serves only one Merchant.
Give every webhook a name (your own label, such as "Order system"), so several are easy to tell
apart. Each delivery names the webhook it was sent for in the X-TBBN-Webhook-Id header.
To change a webhook — its name, address or events — use PATCH /v1/webhooks/subscriptions/:id
(Business: PATCH /v1/webhooks/business-subscriptions/:id). Its signing secret stays the same.
A webhook address must be a public https:// URL. TBBN doesn't follow redirects from it, and an
attempt that takes longer than 10 seconds counts as failed.
Merchant webhooks — TBBN → you
Register a webhook endpoint and TBBN calls it as trades and listings progress:
listing.created, listing.updated, offer.created, offer.accepted, offer.rejected,
trade.created, trade.accepted, trade.completed, trade.cancelled,
trade.counterparty_hold_started, trade.closed_unreciprocated,
trade.external_settlement_started, trade.external_settlement_extended,
trade.external_settlement_reported, checkout.started,
checkout.completed, checkout.failed, reservation.created, reservation.expired,
reservation.failed, fulfillment.started, fulfillment.completed, seller.linked,
seller.unlinked, seller.updated, seller.milestone_reached, review.created,
storefront.followed, api_key.exposed, oauth_client.secret_rotated.
On a Business webhook: booking.checked_in, handover.dropoff_verified, handover.pickup_verified,
handover.verification_locked (too many wrong codes on one booking), device.registered,
device.revoked, location.paused, location.closed and location.reopened, each with branchId.
seller.milestone_reached (milestone: 10) fires once, when a linked seller reaches 10 counted sales,
so you can show the badge. review.created fires when a review of a linked seller goes public, with
its rating. Each carries sellerId, merchantId and merchantSellerRef.
reservation.created/.expired/.failed are the ones to subscribe to if you want to lock your
own inventory in real time as items get reserved for a trade — see
the integration walkthrough's section on this.
seller.linked tells you a seller is now linked to your store: it carries sellerId,
merchantSellerRef (your own id for them) and linkMethod. See
Seller account linking.
seller.unlinked tells you the link ended, with initiatedBy (SELLER, MERCHANT or TBBN): stop
using that sellerId. seller.updated tells you a linked seller changed their storefront address
(storeSlug). oauth_client.secret_rotated tells you a client's secret changed, with clientId.
You only receive events about your own activity: a trade, offer or reservation event goes to the merchants whose listings are part of it (both sides of a trade), and a listing event to that listing's merchant.
Business webhooks — TBBN → you
Space booking lifecycle events, using the same signing/retry mechanics as the merchant webhooks
above. Subscribe per-Business via your dashboard's Webhooks section or
POST /v1/webhooks/business-subscriptions:
space_booking.created, space_booking.consent_required, space_booking.consented,
space_booking.paid, space_booking.cancelled, space_booking.no_show,
space_booking.approval_requested, space_booking.approved, space_booking.completed.
space_booking.approval_requested fires when a booking that requires host approval is created
with no payment taken yet; space_booking.approved fires once the host clears it. A host decline
publishes the existing space_booking.cancelled with a reason describing it, rather than a
separate event type.
With includeMerchantEvents: true, a Business webhook also receives the merchant events listed
above for every Merchant the Business runs. Each of those deliveries adds merchantIds — the
Business's own Merchants the event concerns (a trade between two of your Merchants names both):
{ "type": "trade.completed", "tradeSessionId": "…", "merchantIds": ["6f1c…"], "deliveryId": "…" }
If a Merchant webhook at the same address already receives an event, the Business webhook skips it, so moving to one Business webhook never sends you an event twice. Merchant events delivered to a Business webhook count as webhook deliveries for that Merchant, as they would at a Merchant webhook.
Webhooks you send — you → TBBN
Your own checkout and fulfillment endpoints report back to TBBN:
payment.completed, payment.failed, item.reserved, item.unreserved, item.sold,
item.unavailable, fulfillment.started, fulfillment.completed, returned, cancelled.
Payload shape
Every delivery carries a deliveryId alongside the event's own fields — stable across every
retry attempt of that same delivery, and reused as-is if TBBN or you manually replay it. This is
the field to deduplicate on, not the event's business fields:
{
"type": "checkout.started",
"deliveryId": "dlv_01hxyz...",
"tradeSessionId": "ts_01hxyz...",
"side": "A",
"buyerSide": "B",
"buyerSellerId": "8d0f2c3e-...",
"currency": "USD",
"originalPrice": 500,
"taxBase": 500,
"tradeValue": 500,
"otherSideTradeValue": 550,
"differenceAmount": 0,
"commissionRate": 10,
"commissionAmount": 50,
"commissionOffset": 0,
"commissionShortfall": 50,
"checkoutFee": 0,
"sellerNetCash": 0,
"tradeAmount": 50,
"occurredAt": "2026-07-01T12:00:00Z"
}
Here your items (side A, trade value 500) go to side B's trader, who checks out with you. Side A is
worth less, so A's trader pays the $50 difference at B's store; at yours, B's trader pays your 10%
commission on the $500 listed price — tradeAmount 50 — plus any tax you add. See
the trade quote.
Why not just tradeSessionId + type? Several event types can fire more than once for the
same trade session with the same event name — fulfillment.started/.completed fire once per
side (side: "A" | "B"), and reservation.created/.expired/.failed fire once per listing
in a multi-item trade (listingId). Deduplicating on tradeSessionId + type alone would
silently drop the second side's or second item's event as a false-positive duplicate. Use
deliveryId if you store it; if you'd rather key on business fields instead, use the full tuple
that's actually unique for that event type — tradeSessionId + type + side for
side-specific events, tradeSessionId + type + listingId for per-listing reservation events.
Signature scheme
Every outbound webhook includes X-TBBN-Event-Type, X-TBBN-Delivery-Id, X-TBBN-Webhook-Id and
X-TBBN-Signature: t=<unix_ts>,v1=<hmac_sha256_hex> computed over
${timestamp}.${rawBody} using the endpoint's signing secret (whsec_…, shown with the endpoint on your dashboard). Reject any
request older than a 5-minute replay window. Full verification code ships in every SDK
(verifyWebhookSignature()).
Rotating a secret. POST /v1/webhooks/subscriptions/{id}/rotate-secret, or New secret on
your dashboard, makes a new secret. For the next 24 hours the header carries a signature for each
(t=…,v1=<new>,v1=<old>), and matching any one v1 is enough, so your server can change over
without missing a delivery. If you wrote your own verifier, check every v1 in the header. SDK
versions from before this was added read only one: update yours before you rotate.
Adding a webhook or rotating a secret from a signed-in session on a live account needs a confirmation code; see Step-up verification. Adding one with an API key doesn't.
Delivery guarantees
At-least-once delivery. Any response other than a 2xx — or no response — counts as a failure:
- Right away: 3 attempts, a second or two apart.
- Then on a schedule: 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours after the previous attempt — about 21 hours in all — so an endpoint that is down for hours still receives the event.
- Then the dead-letter queue, where the delivery can be replayed from your dashboard or with
POST /v1/webhooks/deliveries/:id/replay(Space webhooks:POST /v1/webhooks/business-deliveries/:id/replay). A replay that fails goes back to the dead-letter queue rather than restarting the schedule.
Each attempt is signed afresh, so a retry passes the five-minute replay check above. A delivery's
status is FAILED while retries remain, with nextAttemptAt showing when the next one is due.
Retries for a subscription you disable stop. Retries are not billed again — a delivery counts once.
Neither retries nor a manual replay are automatically deduplicated on your end — build your
handler to be idempotent, keyed by deliveryId (see "Payload shape" above), same principle as
API conventions's idempotency-key requirement on the
calls you make to TBBN.
