Webhooks
Webhooks deliver a signed HTTP POST to your endpoint when events occur on a verified website (e.g. its AI node is republished). Webhooks are a Pro and Business feature.
Subscribing
Manage subscriptions from the dashboard → a website → Webhooks, or via the REST API (organization_admin+):
POST /v1/websites/{websiteId}/webhooks
{ "url": "https://example.com/hooks/bainquet", "events": ["node.published"] }The response includes a signing secret, shown exactly once — store it now; it cannot be retrieved later (only its hash is kept). Rotate by deleting and recreating the subscription.
Events currently emitted: node.published, verification.lost, verification.restored.
Delivery
Each delivery is an HTTP POST with a JSON body and these headers:
| Header | Meaning |
|---|---|
X-Bainquet-Event | the event type (e.g. node.published) |
X-Bainquet-Event-Id | stable id of the source event (use it to deduplicate) |
X-Bainquet-Delivery | unique id of this delivery attempt |
X-Bainquet-Signature | t=<unix>,v1=<hex HMAC-SHA256> (see below) |
Delivery is at-least-once — the same X-Bainquet-Event-Id may arrive more than once (retries). Make your handler idempotent on that id. Respond 2xx quickly; non-2xx (or a timeout) is retried with exponential backoff, and a persistently failing endpoint is eventually disabled.
Verifying the signature
Important — the HMAC key is
sha256(secret), not the secret itself. bAInquet stores only the SHA-256 hash of your signing secret, and signs with that hash. So your verifier must hash the one-time secret first, then HMAC.
The signed message is "{t}.{rawBody}" — the exact raw request body bytes, not a re-serialized copy. Reject if t is older than ~5 minutes (replay protection).
import { createHmac, timingSafeEqual } from "node:crypto";
// `secret` = the value shown ONCE when you created the webhook.
function verify(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(
signatureHeader.split(",").map((kv) => kv.split("=")),
);
const t = parts.t;
const v1 = parts.v1;
if (!t || !v1) return false;
// Replay window: 5 minutes.
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
// KEY = sha256(secret) as a HEX STRING — bAInquet stores the secret's hash as
// hex (secret_hash) and HMACs with that hex string as the key, so you must use
// the HEX digest here (NOT the raw 32-byte Buffer).
const { createHash } = require("node:crypto");
const hashedKeyHex = createHash("sha256").update(secret, "utf8").digest("hex");
const expected = createHmac("sha256", hashedKeyHex)
.update(`${t}.${rawBody}`, "utf8")
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(v1, "hex");
return a.length === b.length && timingSafeEqual(a, b);
}In other languages the recipe is the same: key = SHA256_HEX(secret) (the lowercase hex string, not raw bytes), then signature = HMAC_SHA256_HEX(key, t + "." + rawBody), compared in constant time.