FurniOpsDocs
TypeScript SDK

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's whsec_ 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

server.ts
// 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:

route.ts
// 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?)

ArgumentTypeMeaning
payloadstring or Uint8ArrayThe raw body as received. A re-serialised copy never verifies.
headersHeaders or a recordThe request headers, as fetch or Node gives them.
secretstring or string[]The endpoint secret, or several during a rotation.
options.toleranceSecondsnumberThe timestamp window, either way. Default 300.
options.nowDateThe 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:

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

On this page