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
| 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>.
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.