FurniOpsDocs

Handle webhooks securely

Verify Standard Webhooks signatures with or without the SDK, refuse replays, rotate secrets without downtime, and answer in a way FurniOps understands.

Anyone can POST to your webhook URL. A signature is how your handler knows a delivery came from FurniOps, was meant for this endpoint, and was not changed or replayed on the way. FurniOps signs every delivery with the Standard Webhooks v1 scheme, so any Standard Webhooks library can verify it too.

The three headers

HeaderValue
webhook-idThe event's ID (evt_…). The same on every retry of one event.
webhook-timestampWhen this attempt was signed, in Unix seconds.
webhook-signaturev1, and the base64 HMAC-SHA256 signature. During a secret rotation, two signatures separated by a space.

The signature is HMAC-SHA256 over {webhook-id}.{webhook-timestamp}.{body}, keyed with the endpoint secret: the bytes the base64 after whsec_ decodes to.

Each delivery also carries FurniOps-Version, the API version its event is shaped for.

Verify with the SDK

furniops.webhooks.verify (also exported on its own as verifyWebhook) checks the headers, the timestamp and the signature, and returns the typed event:

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);

It runs on Web Crypto, so the same call works in Node, Deno, Bun, Cloudflare Workers and other edge runtimes. On failure it throws WebhookVerificationError with a reason:

reasonMeaning
headersA webhook-* header is missing, or webhook-id is malformed.
timestampwebhook-timestamp is not Unix seconds, or is more than 300 seconds from your clock, either way.
signatureNo v1 signature matches any of the secrets you passed.
payloadThe signature matched, but the body is not JSON.
secretA secret you passed is not a valid whsec_ secret: a bug on your side.

Answer a failed verification with a 4xx and do nothing else. Do not log the secret.

Verify without the SDK

Use any Standard Webhooks library, or these few lines of node:crypto. This file is tested against the SDK's verifier on every build:

verify.ts
const TOLERANCE_SECONDS = 300;

/** Throws unless the delivery is signed by one of `secrets` (whsec_…) within five minutes. */
export function verifyDelivery(
  rawBody: string,
  headers: Record<string, string | undefined>,
  secrets: readonly string[],
  now = Date.now(),
): unknown {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = headers["webhook-signature"];
  if (!id || !timestamp || !signatures) throw new Error("missing webhook-* headers");

  // A replayed delivery is refused once its timestamp is more than five minutes from your clock.
  if (!/^\d+$/.test(timestamp) || Math.abs(now / 1000 - Number(timestamp)) > TOLERANCE_SECONDS)
    throw new Error("timestamp outside the tolerance");

  const signed = `${id}.${timestamp}.${rawBody}`;
  // The header holds one or more space-separated "v1,<base64>" signatures (two during a rotation).
  const candidates = signatures
    .split(" ")
    .filter((s) => s.startsWith("v1,"))
    .map((s) => Buffer.from(s.slice(3), "base64"));

  for (const secret of secrets) {
    // The HMAC key is the base64 after the whsec_ prefix, decoded to bytes.
    const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
    const expected = createHmac("sha256", key).update(signed).digest();
    // Constant-time comparison: never compare signatures with ===.
    if (candidates.some((c) => c.length === expected.length && timingSafeEqual(c, expected)))
      return JSON.parse(rawBody);
  }
  throw new Error("no signature matches");
}

Two details decide whether a hand-written verifier is secure:

  • Verify the raw bytes you received. Parsing the JSON and serialising it again changes whitespace and key order, and the signature no longer matches. In Express, read the body with express.raw({ type: "application/json" }) for this route; in Next.js route handlers, await request.text().
  • Compare signatures in constant time (timingSafeEqual, crypto.subtle.verify), never with ===, which leaks how much of a forged signature was right.

Replays and duplicates

Two different problems, two defences:

  • Replays of a captured delivery are refused by the timestamp check: an attempt is signed when it is sent, and a copy replayed more than five minutes later fails. Keep your server's clock synchronised (NTP).
  • Duplicates are normal: delivery is at least once, so a delivery whose answer was lost is sent again with the same webhook-id. Record the IDs you have processed (a unique index is enough), and skip one you have seen. Apply changes by version, too: then even a duplicate that slips through changes nothing.

Rotate a secret without downtime

// The old secret keeps signing beside the new one for `overlap_hours` (0 to 72, default 24).
const rotated = await furniops.webhookEndpoints.rotateSecret(endpoint.id, { overlap_hours: 24 });
console.log("new secret ends", rotated.secret.slice(-4));

POST /v1/webhook-endpoints/{id}/rotate-secret answers the new secret, once. For the overlap (overlap_hours, 0 to 72, 24 by default) every delivery is signed with both the old and the new secret, so the header carries two signatures and your handler accepts it whichever secret it holds. Within the overlap:

  1. Store the new secret.
  2. Deploy it. If you run many instances that update one by one, pass both secrets to verify ([current, previous]) until every instance has the new one.
  3. Remove the old secret.

Rotate at once (overlap_hours: 0) if a secret has leaked.

How to answer

You answerFurniOps does
any 2xxCounts it a success. The body is ignored.
410 GoneDisables the endpoint at once. Answer it only when the URL is gone for good.
429, 502 or 504 with Retry-AfterRetries when it says.
a webhook-delivery: abort-message headerStops retrying this one event.
a 3xxFails the attempt: redirects are never followed. Use the final URL.
anything else, or nothing within 15 sFails the attempt and retries on the schedule.

Answer within a few seconds: verify, record the event, queue the work, answer 2xx, and do the work afterwards. The first 1 KiB of your answer's body is kept in the delivery log, which helps when you debug a failing endpoint; do not put secrets in it.

Where deliveries come from

FurniOps delivers only to public addresses: an endpoint URL that resolves to a private, loopback or link-local address is refused when it is created and checked again on every connection, so a DNS change cannot turn a delivery against your internal network. Live endpoints must use HTTPS. Because the signature already proves a delivery is from FurniOps, you do not need an IP allow-list for it.

Debugging deliveries

  • GET /v1/webhook-endpoints/{id}/deliveries lists recent deliveries, filterable by status and type; each delivery records its attempts with your status code and the start of your answer. The log keeps 30 days.
  • POST /v1/webhook-endpoints/{id}/deliveries/{delivery}/resend sends one again.
  • POST /v1/webhook-endpoints/{id}/test sends a webhook.test event now.
  • furniops listen forwards events to your machine, signed like real ones.

On this page