Your first webhook
Create a webhook endpoint, verify its signatures with the SDK, and test it on your machine with furniops listen.
A webhook endpoint is a URL of yours that FurniOps POSTs events to as they happen. Each delivery
is signed with the endpoint's secret (Standard Webhooks
v1), retried for about three days until you answer 2xx, and kept in a delivery log you can
read and replay.
Write the handler
Your handler does three things, in this order: verify the signature over the raw body,
skip events it has already processed (the event ID is the key), and answer 2xx quickly,
doing the real work afterwards.
// 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);furniops.webhooks.verify checks the three webhook-* headers, that webhook-timestamp is
within five minutes of your clock, and the HMAC-SHA256 signature, in constant time. It returns the
typed event, or throws a WebhookVerificationError whose reason says what failed. See
Handle webhooks securely for the details and for verifying without
the SDK.
Test it locally with furniops listen
FurniOps only delivers to public addresses, so instead of a tunnel, the CLI holds the event stream and forwards each event to your handler, signed exactly as a real delivery is, with a local secret of its own.
furniops login --with-api-key # a fo_test_ key, from stdin or typed hidden
furniops listen --forward-to localhost:3000/webhooksReady. Forwarding events to http://localhost:3000/webhooks. Your webhook signing secret is whsec_… (^C to quit)Start your handler with that secret, then emit a sample event from another terminal:
FURNIOPS_WEBHOOK_SECRET=whsec_… node server.ts
furniops trigger offer.changedlisten prints one line per delivery with your handler's status and how long it took. See
furniops listen for filtering by event type and for fat payloads.
Create the endpoint
When your handler is deployed at a public HTTPS URL, create an endpoint for it. Choose the event
types it needs (* for every type, including future ones):
curl https://api.furniops.com/v1/webhook-endpoints \
-H "Authorization: Bearer $FURNIOPS_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/furniops",
"event_types": ["offer.changed", "offer.created", "product.discontinued"]
}'Or with the SDK:
const endpoint = await furniops.webhookEndpoints.create({
url: "https://example.com/hooks/furniops",
description: "Price and stock sync",
event_types: ["offer.changed", "offer.created", "product.discontinued"],
payload: "thin",
});
// The signing secret is returned once, on creation (and on rotation). Store it as a secret.
console.log(endpoint.id, endpoint.secret.slice(0, 6));The response includes secret, a whsec_… value shown only in this response (and again
after a rotation). Store it with your other secrets; your handler verifies with it.
An endpoint created with a test key is a test-mode endpoint and receives only test events. Each organisation can have up to 20 endpoints.
Send it a test event
POST /v1/webhook-endpoints/{id}/test sends a webhook.test event to the endpoint at once,
signed like any other, whatever it subscribes to. Then read how the delivery went:
// Sends a `webhook.test` event now, signed like any other, whatever the endpoint subscribes to.
const delivery = await furniops.webhookEndpoints.sendTest(endpoint.id);
const detail = await furniops.webhookEndpoints.getDelivery(endpoint.id, delivery.id);
console.log(detail.status, detail.last_status);In test mode, POST /v1/test/events emits a real-shaped
sample of any event type to every test endpoint subscribed to it.
Endpoint options
| Field | Default | What it does |
|---|---|---|
event_types | (required) | Up to 50 types, or * for all of them, present and future. |
filters.sources | every source | Only offer and source-health events from these sources. Product and variant events are not narrowed. |
filters.categories | every category | Only events about products in these categories or below, and their variants and offers. Source-health events are not narrowed. |
payload | thin | thin sends the changed fields; fat also sends object, the product, variant or offer as of the event's version, so you need no read back. |
coalesce_seconds | 0 | A burst window, 0 to 300 s. Changes to one object waiting to be sent are combined into one event (each field's first old and last new). |
api_version | the request's version | The API version the endpoint's events are shaped for, named in each delivery's FurniOps-Version header. |
Every option, limit and delivery rule is in Webhook endpoints and deliveries.
What a delivery looks like
POST /hooks/furniops HTTP/1.1
Content-Type: application/json
webhook-id: evt_034XqBaL2mRLTZPXyRbaUi
webhook-timestamp: 1791108002
webhook-signature: v1,<base64 of the HMAC-SHA256 signature>
FurniOps-Version: 2026-10-04{
"id": "evt_034XqBaL2mRLTZPXyRbaUi",
"type": "offer.changed",
"spec_version": "1.0",
"source": "furniops/catalog",
"subject": "ofr_034XqBaL2mRLTZPXyRbaUg",
"time": "2026-10-04T10:00:02Z",
"tenant_id": "org_034XqBaL2mRLTZPXyRbaUj",
"version": 12,
"schema_version": 1,
"correlation_id": "run_034XqBaL2mRLTZPXyRbaUk",
"causation_id": null,
"livemode": true,
"data": {
"id": "ofr_034XqBaL2mRLTZPXyRbaUg",
"variant": "var_034XqBaL2mRLTZPXyRbaUf",
"source": "src_034XqBaL2mRLTZPXyRbaUh",
"catalogue": "cat_034XqBaL2mRLTZPXyRbaUd",
"location": "DAL",
"availability": "in_stock",
"version": 12,
"facets": ["price"],
"fields": [
{
"path": "cost",
"old": 89900,
"new": 94900,
"provenance": {
"source": "src_034XqBaL2mRLTZPXyRbaUh",
"method": "mapped",
"confidence": 1,
"observed_at": "2026-10-04T10:00:00Z"
}
}
]
}
}
Retries, failures and recovery
- Only a
2xxanswer is a success. Redirects are not followed. - A failed delivery is retried on a schedule: at once, then after 5 s, 5 min, 30 min, 2 h, 5 h, 10 h, 10 h, 14 h, 14 h and 14 h. That is eleven attempts over about 2.9 days.
429,502and504answers that sendRetry-Afterare retried when it says.- Events of one subject (one product, variant or offer) are delivered in order to an endpoint. A slow endpoint never slows anyone else's.
- An endpoint that answers
410 Goneis disabled at once. One with no successful delivery for 72 hours is disabled too;status_reason.codesays which (goneorfailing). - Fix the receiver, set the endpoint's
statusback toenabledwithPATCH /v1/webhook-endpoints/{id}(which takesIf-Match), then send the failed deliveries of the outage again:
// Once a broken receiver is fixed: send every failed delivery of the range again, oldest first.
const recovery = await furniops.webhookEndpoints.recoverDeliveries(endpoint.id, {
since: "2026-10-03T00:00:00Z",
});
console.log(`${recovery.requeued} deliveries queued again`);Events never stored for an endpoint while it was disabled are not sent by a recovery; read them from the change feed, which keeps 30 days.