Receiving webhooks
Get a signed request when a job finishes, and verify its signature.
A webhook endpoint receives a signed POST when a job reaches a terminal state. With an endpoint
registered, Fabric tells you an import completed, so you do not have to poll for it 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
The secret is returned only in this response, and no endpoint reads it back. If you lose it,
call POST /v1/brands/{brandId}/webhooks/{webhookId}/rotate-secret to issue a new one. The old
secret stops verifying immediately, so have your receiver ready for the new secret before you
rotate.
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 |
Request format
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 }
}Verify the signature
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 part of the signed material, so a captured delivery cannot be replayed later without breaking the signature.
Delivery
Delivery is at-least-once, so the same event can arrive twice. Deduplicate on
Fabric-Event-Id.
Return a 2xx to accept a delivery. Any other response is retried eight times over about an
hour, with a longer wait each time. Respond first and process the event afterwards: a delivery
that takes longer than 10 seconds is abandoned and retried.
An endpoint that fails 20 deliveries in a row is disabled. Any success resets the count, so an
endpoint that fails now and then stays enabled, and only one that has stopped responding is
disabled. A disabled endpoint is not deleted. It keeps the reason it was disabled, and
POST /v1/brands/{brandId}/webhooks/{webhookId}/enable restarts delivery once you have fixed it.
Check delivery attempts
GET /v1/brands/{brandId}/webhooks/{webhookId}/deliveries returns every attempt, with the status
code and reply your endpoint returned and how long it took. Check it to confirm Fabric sent the
event before you search your own logs.