Webhooks
Verify a webhook delivery with the SDK in Node, Next.js, Workers and other runtimes, and type the events you receive.
The SDK verifies a delivery and hands you the typed event in one call. It is available two ways:
furniops.webhooks.verify(...)on a client;verifyWebhook(...), imported on its own, which needs no client and no API key: a receiver only needs the endpoint'swhsec_secret.
Both check the three webhook-* headers, that webhook-timestamp is within 300 seconds of your
clock, and the v1 HMAC-SHA256 signature in constant time, on Web Crypto.
Node
// The endpoint's whsec_… secret; during a rotation, pass both: [current, previous].
const secrets = [process.env["FURNIOPS_WEBHOOK_SECRET"] ?? ""];
createServer(async (req, res) => {
// Verify the exact bytes received: a re-serialised body never matches the signature.
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
const raw = Buffer.concat(chunks);
let event: WebhookEvent;
try {
event = await furniops.webhooks.verify(raw, req.headers, secrets);
} catch (error) {
if (error instanceof WebhookVerificationError) {
// reason: "headers" | "timestamp" | "signature" | "payload" | "secret"
res.writeHead(400).end(error.reason);
return;
}
throw error;
}
// The event ID is the deduplication key: a retry delivers the same event again.
if (!seen.has(event.id)) {
seen.add(event.id);
await enqueue(event); // do the work after answering, not before
}
// Any 2xx is a success; answer within seconds, or the delivery is retried.
res.writeHead(204).end();
}).listen(3000);Fetch-style runtimes
Next.js route handlers, Cloudflare Workers, Deno, Bun and Hono all hand you a Request. Read its
body with request.text(), never request.json(), and pass its headers as they are:
// A fetch-style handler: a Next.js route handler (app/api/hooks/route.ts), a Cloudflare Worker,
// Deno, Bun or Hono. verifyWebhook needs no client and no API key, only the endpoint secret.
export async function POST(request: Request): Promise<Response> {
const raw = await request.text(); // the exact body, before any JSON parsing
try {
const event = await verifyWebhook(raw, request.headers, env.FURNIOPS_WEBHOOK_SECRET);
if (!(await alreadyProcessed(event.id))) await enqueue(event);
return new Response(null, { status: 204 });
} catch (error) {
if (error instanceof WebhookVerificationError)
return new Response(error.reason, { status: 400 });
throw error;
}
}Arguments
verifyWebhook(payload, headers, secret, options?)
| Argument | Type | Meaning |
|---|---|---|
payload | string or Uint8Array | The raw body as received. A re-serialised copy never verifies. |
headers | Headers or a record | The request headers, as fetch or Node gives them. |
secret | string or string[] | The endpoint secret, or several during a rotation. |
options.toleranceSeconds | number | The timestamp window, either way. Default 300. |
options.now | Date | The receiver's clock. Default now; useful in tests. |
// Tests can pin the clock; a stricter receiver can narrow the window (seconds, either way).
await verifyWebhook("{}", new Headers(), [env.FURNIOPS_WEBHOOK_SECRET], {
toleranceSeconds: 60,
now: new Date("2026-10-04T10:00:00Z"),
}).catch(() => undefined);It resolves to a WebhookEvent, or throws WebhookVerificationError whose reason is
headers, timestamp, signature, payload or secret; see
Handle webhooks securely for what each means.
Typed events
WebhookEvent is a union over every event type, discriminated by type. Narrowing on it types
data:
export async function onEvent(event: WebhookEvent): Promise<void> {
if (event.type !== "offer.changed") return;
const { data } = event;
// Apply only newer versions: a late retry of v12 after v13 must change nothing.
if (data.version <= (await savedVersion(data.id))) return;
if (!data.facets.includes("price")) return; // a stock or availability change
for (const field of data.fields) {
// Each changed field, with its old and new value and where the new value came from.
console.log(field.path, field.old, "→", field.new, field.provenance?.source);
if (field.path === "cost") await saveOffer(data.id, data.version, field.new);
}
}The change feed, the stream and webhooks deliver the same envelope, so one handler can serve all three.
Managing endpoints
Endpoints are managed with furniops.webhookEndpoints: create, list, get, update,
delete, rotateSecret, sendTest, listDeliveries, getDelivery, resendDelivery and
recoverDeliveries. See Your first webhook for a walk-through and
Resources and methods for the full list.