Fabric documentation

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

EventSent when
import_completed · import_failedAn import finished, or gave up
enrichment_run_completed · enrichment_run_failedAn enrichment run finished, or gave up
export_readyAn export's artifact is ready to fetch
publish_run_completed · publish_run_failedA 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.