Developer guide

Webhooks

Subscribe an HTTPS endpoint to BTAB store events. BTAB POSTs a signed JSON payload to your endpoint when the event happens — no polling.

Written with the code it describes, in the same pull request. Plain Markdown: /developers/webhooks.md

Subscribe an HTTPS endpoint to BTAB store events. BTAB POSTs a signed JSON payload to your endpoint when the event happens — no polling.

Events

EventFires when
order.createda new order is placed on your store
order.shippedan order transitions to shipped
order.fulfilledan order transitions to fulfilled
order.completedan order completes
site.publishedyour storefront is published
product.createdyou listed a product in your store
product.updateda product in your store changed: its details, price or variants, or your listing of it (repriced, retitled, shown or hidden on a channel)
product.deleteda product left your store: you delisted it, or it was withdrawn from the catalogue
inventory.updatedstock of a product in your store changed (an order reserved or released it, the supplier updated it, goods were received)

The product.* and inventory.updated events are coalesced and ids-only. Many changes to one product within a short window (30 seconds by default) arrive as one delivery, sent when the window closes. The payload carries only data.product_id (plus data.variant_id when one variant changed). Read the current product and stock from the API when it arrives. Never treat the delivery itself as the new state. Each delivery has its own event_key (product.updated:<product id>.<timestamp>).

Subscribe to specific events or ["*"] for all. Browsing events (product views, checkout starts) are deliberately not offered — they are a high-volume streaming class, not webhook material.

Ordering is NOT guaranteed. Deliveries can arrive out of order and (rarely) more than once. Key your processing on event_key (<event>:<entity id>) and your own entity state, never on arrival sequence.

Managing subscriptions

All endpoints are under /api/v1/vendor/webhooks, authenticated exactly like the rest of the vendor API (Authorization: Bearer <api key>). Writes require a read_write key.

MethodPathWhat
GET/list subscriptions (+ the valid event set)
POST/create — body { "url": "https://...", "events": ["order.created"] }
GET/:idone subscription
PUT/:idupdate url / events / active (re-enabling clears the failure streak)
DELETE/:iddelete
POST/:id/rotate-secretnew signing secret (shown once; signs immediately)
GET/:id/deliverieslast 100 delivery attempts
POST/:id/deliveries/:deliveryId/resendqueue a redelivery of one event

Limits: 10 subscriptions per store. URLs must be https://, on a publicly resolvable host — private/internal addresses are refused at create AND at every delivery.

The signing secret is returned exactly once — in the create and rotate-secret responses (whsec_...). Store it immediately; BTAB keeps only an encrypted copy and cannot show it again.

Delivery

Each delivery is an HTTP POST to your URL:

POST /your/endpoint
Content-Type: application/json
X-Btab-Event: order.created
X-Btab-Delivery-Id: <subscription>:<event_key>:<attempt>
X-Btab-Signature: t=1756100000, v1=6a2f...c9

{
  "event": "order.created",
  "event_key": "order.created:4211",
  "created_at": "2026-08-25T02:10:00.000Z",
  "data": { "order": { ... } }
}

Respond with any 2xx within 10 seconds. The response body is ignored. Redirects are not followed. Respond 410 Gone to permanently unsubscribe the endpoint (it is disabled immediately).

Retries and auto-disable

A non-2xx response or timeout is retried on a backoff ladder: 1m → 5m → 30m → 2h → 12h (5 attempts). After the last failure the event is dead-lettered and the store owner is emailed. After 20 consecutive dead-lettered events the subscription is auto-disabled (re-enable it with PUT /:id {"active": true} once the endpoint is healthy). Every attempt is visible in the delivery log.

Verifying signatures (do this)

The scheme is Stripe's, verbatim: X-Btab-Signature: t=<unix seconds>, v1=<hex HMAC-SHA256 of "<t>.<raw body>" with your secret>.

const crypto = require('crypto');

function verifyBtabSignature(secret, rawBody, header, toleranceSeconds = 300) {
  const parts = Object.fromEntries(
    header.split(',').map((p) => p.trim().split('=', 2))
  );
  const t = Number(parts.t);
  if (!Number.isFinite(t) || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false; // replay window
  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${t}.${rawBody}`)
    .digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Express: mount with a raw body parser — the signature covers the RAW bytes.
app.post('/btab-webhook', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verifyBtabSignature(process.env.BTAB_WEBHOOK_SECRET, req.body.toString('utf8'), req.get('X-Btab-Signature') || '')) {
    return res.status(401).end();
  }
  const event = JSON.parse(req.body);
  // Dedupe on event.event_key, then process.
  res.status(200).end();
});

Always verify. An unverified endpoint will happily process forged events from anyone who finds its URL.

Reference consumer

A complete runnable consumer — signature verification, event_key deduplication, ack-fast-then-process — lives at examples/webhook-consumer.js. It is the template BTAB's own connectors (Xero first) start from; start yours there too.