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
| Header | Value |
|---|---|
webhook-id | The event's ID (evt_…). The same on every retry of one event. |
webhook-timestamp | When this attempt was signed, in Unix seconds. |
webhook-signature | v1, 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:
// 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:
reason | Meaning |
|---|---|
headers | A webhook-* header is missing, or webhook-id is malformed. |
timestamp | webhook-timestamp is not Unix seconds, or is more than 300 seconds from your clock, either way. |
signature | No v1 signature matches any of the secrets you passed. |
payload | The signature matched, but the body is not JSON. |
secret | A 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:
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 byversion, 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:
- Store the new secret.
- 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. - Remove the old secret.
Rotate at once (overlap_hours: 0) if a secret has leaked.
How to answer
| You answer | FurniOps does |
|---|---|
any 2xx | Counts it a success. The body is ignored. |
410 Gone | Disables the endpoint at once. Answer it only when the URL is gone for good. |
429, 502 or 504 with Retry-After | Retries when it says. |
a webhook-delivery: abort-message header | Stops retrying this one event. |
a 3xx | Fails the attempt: redirects are never followed. Use the final URL. |
| anything else, or nothing within 15 s | Fails 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}/deliverieslists recent deliveries, filterable bystatusandtype; 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}/resendsends one again.POST /v1/webhook-endpoints/{id}/testsends awebhook.testevent now.furniops listenforwards events to your machine, signed like real ones.
Events reference
Every event FurniOps publishes, generated from the API's own contract. What each one means, which channels deliver it (webhooks, the change feed, the stream), whether test mode can emit a sample, and its payload.
Idempotent requests
Retry any POST safely with an Idempotency-Key. What is stored, for how long, and what each answer means.