<!-- Generated from Btab's docs/api/WEBHOOKS.md. The same guide as a web page: https://btab.app/developers/webhooks -->

# Webhooks

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

## Events

| Event | Fires when |
|---|---|
| `order.created` | a new order is placed on your store |
| `order.shipped` | an order transitions to shipped |
| `order.fulfilled` | an order transitions to fulfilled |
| `order.completed` | an order completes |
| `site.published` | your storefront is published |
| `product.created` | you listed a product in your store |
| `product.updated` | a product in your store changed: its details, price or variants, or your listing of it (repriced, retitled, shown or hidden on a channel) |
| `product.deleted` | a product left your store: you delisted it, or it was withdrawn from the catalogue |
| `inventory.updated` | stock 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.

| Method | Path | What |
|---|---|---|
| GET | `/` | list subscriptions (+ the valid event set) |
| POST | `/` | create — body `{ "url": "https://...", "events": ["order.created"] }` |
| GET | `/:id` | one subscription |
| PUT | `/:id` | update `url` / `events` / `active` (re-enabling clears the failure streak) |
| DELETE | `/:id` | delete |
| POST | `/:id/rotate-secret` | new signing secret (shown once; signs immediately) |
| GET | `/:id/deliveries` | last 100 delivery attempts |
| POST | `/:id/deliveries/:deliveryId/resend` | queue 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>`.

```js
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`](/docs/examples/webhook-consumer.js). It is the
template BTAB's own connectors (Xero first) start from; start yours there too.
