Receiving webhooks
Learn that a job finished without polling for it, and verify what arrives.
A webhook endpoint receives a signed POST when a job reaches a terminal state. Register one and
Fabric tells you an import completed instead of you asking every thirty seconds.
Register an endpoint
curl -X POST "https://fabric.inc/api/v1/brands/$BRAND/webhooks" \
-H "Authorization: Bearer $FABRIC_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/hooks/fabric","events":["import_completed"]}'{
"id": "whe_...",
"url": "https://example.com/hooks/fabric",
"secret": "whsec_...",
"secretHint": "9fQ2"
}The secret appears once
This response is the only place it is ever returned. No endpoint reads it back. If you lose it,
rotate — POST /v1/brands/{brandId}/webhooks/{webhookId}/rotate-secret issues a new one and the
old one stops verifying immediately, so cut over before rotating rather than after.
Omit events to receive every type. The URL must be public HTTPS; loopback and private addresses
are refused.
Events
| Event | Sent when |
|---|---|
import_completed · import_failed | An import finished, or gave up |
enrichment_run_completed · enrichment_run_failed | An enrichment run finished, or gave up |
export_ready | An export's artifact is ready to fetch |
publish_run_completed · publish_run_failed | A publish finished, or gave up |
What arrives
POST /hooks/fabric
Content-Type: application/json
Fabric-Event-Id: evt_01J8...
Fabric-Event-Type: import_completed
Fabric-Signature: t=1757260800,v1=5257a869...{
"id": "evt_01J8...",
"type": "import_completed",
"createdAt": "2026-09-07T18:00:00.000Z",
"data": { "importJobId": "imp_...", "productCount": 2431 }
}Verifying
Compute HMAC-SHA256 over "<t>.<raw body>" with your secret and compare it to v1. Use the raw
body, before any JSON parsing — re-serializing changes the bytes and the signature will not match.
import { createHmac, timingSafeEqual } from "node:crypto";
const verify = (rawBody: string, header: string, secret: string): boolean => {
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("=") as [string, string])
);
// Reject a stale delivery before spending a hash on it.
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) {
return false;
}
const expected = createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1);
return a.length === b.length && timingSafeEqual(a, b);
};Compare in constant time and reject anything older than 300 seconds. The timestamp is inside the signed material, so a captured delivery cannot be replayed later without breaking the signature.
Delivery
At-least-once. Dedupe on Fabric-Event-Id — the same event can arrive twice, and processing it
twice is your side to prevent.
Answer 2xx to accept. Anything else is retried eight times over roughly an hour on a widening
backoff. Answer quickly and do the work afterwards: a delivery that takes longer than 10 seconds is
abandoned and retried.
An endpoint that fails 20 consecutive deliveries is disabled — any success resets the count, so
a flaky endpoint is never disabled, only one that has stopped answering. It is disabled, never
deleted: you will find it with the reason recorded, and
POST /v1/brands/{brandId}/webhooks/{webhookId}/enable starts delivery again once it is fixed.
When nothing arrives
GET /v1/brands/{brandId}/webhooks/{webhookId}/deliveries returns every attempt — the status code
we received, what your endpoint replied, and how long it took. That answers whether we sent it
before you go looking at your own logs.