Webhooks
Webhooks let your backend receive push notifications for Fabric events instead of polling. Register a URL, choose which events to subscribe to, and Fabric will POST a signed JSON payload every time a matching event fires.
Use webhooks alongside SSE/WebSocket streaming for a complete integration: SSE for real-time UI updates, webhooks for backend-to-backend pipelines, alerting, and async processing.
Quick Start
Section titled “Quick Start”The signing secret is generated server-side and returned once in the create response. For local development, fabric setup provides it as FABRIC_WEBHOOK_SECRET.
import { FabricClient } from "@fabric-platform/sdk";
const fabric = new FabricClient({ apiKey: "fab_xxx" });
const result = await fabric.webhooks.create(orgId, { url: "https://your-app.com/fabric/webhook", event_filter: ["workflow.run.completed", "workflow.run.failed"],});// result.secret is "whsec_..." — returned ONCE. Store it securely.// result.id, result.url, result.active, etc. are the webhook metadata.from fabric_platform import FabricClient
fabric = FabricClient(api_key="fab_xxx")
result = fabric.create_webhook( org_id=org_id, url="https://your-app.com/fabric/webhook", events=["workflow.run.completed", "workflow.run.failed"],)# result["secret"] is "whsec_..." — returned ONCE. Store it securely.let result = client.create_webhook( org_id, "https://your-app.com/fabric/webhook", vec!["workflow.run.completed", "workflow.run.failed"],).await?;// result["data"]["secret"] is "whsec_..." — returned ONCE.curl -X POST https://gofabric.dev/v1/organizations/$ORG_ID/webhooks \ -H "Authorization: Bearer fab_xxx" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-app.com/fabric/webhook", "event_filter": ["workflow.run.completed", "workflow.run.failed"], "max_retries": 5 }'# Response includes "secret": "whsec_..." — shown once, store it.Event Types
Section titled “Event Types”Every event has a dotted string kind. Use event_filter to subscribe to specific events — an empty filter receives all events.
Workflow Run Lifecycle
Section titled “Workflow Run Lifecycle”| Event | Description |
|---|---|
workflow.run.created | Run was submitted |
workflow.run.queued | Run entered the priority queue |
workflow.run.promoted | Run was promoted in the queue |
workflow.run.started | Execution began |
workflow.run.completed | Run finished successfully |
workflow.run.failed | Run failed (check payload.error) |
workflow.run.cancelled | Run was cancelled by a user or API call |
workflow.run.waiting | Run is waiting for external input |
workflow.run.paused | Run was paused |
Workflow Node Lifecycle
Section titled “Workflow Node Lifecycle”| Event | Description |
|---|---|
workflow.node.started | A node within a run started executing |
workflow.node.completed | A node finished successfully |
workflow.node.failed | A node failed |
workflow.node.progress | Intra-node progress (emitted by SDK log.emit() during long-running tasks) |
Job Lifecycle
Section titled “Job Lifecycle”| Event | Description |
|---|---|
job.created | A single-node job was submitted |
job.started | Job execution began |
job.completed | Job finished successfully |
job.failed | Job failed |
Schedule Events
Section titled “Schedule Events”| Event | Description |
|---|---|
workflow.schedule.triggered | A scheduled workflow was triggered |
workflow.schedule.skipped | A scheduled trigger was skipped (e.g. concurrency limit) |
workflow.schedule.failed | A scheduled trigger failed to launch |
System Events
Section titled “System Events”| Event | Description |
|---|---|
webhook.delivery.exhausted | A webhook delivery exhausted all retries (dead letter) |
organization.created | A new organization was created |
invitation.created | An invitation was sent |
invitation.accepted | An invitation was accepted |
invitation.revoked | An invitation was revoked |
Payload Structure
Section titled “Payload Structure”Every webhook delivery POSTs a JSON DomainEvent:
{ "id": "evt_a1b2c3d4-...", "kind": "workflow.run.completed", "organization_id": "org-uuid", "workflow_id": "wf-uuid", "run_id": "run-uuid", "node_key": null, "payload": { "sayiir_instance_id": "fab_run-uuid", "state": { "workspace_id": "xxx", "triggered_by": "yyy" }, "output_url": "https://gofabric.dev/v1/workflows/runs/run-uuid/output" }, "timestamp": "2026-04-18T12:00:00Z"}Fields are null when not applicable — for example, node_key is only set on node-level events, and job_id is only set on job events.
Downloading Artifacts
Section titled “Downloading Artifacts”Terminal events (workflow.run.completed, workflow.run.failed, workflow.run.cancelled) include an output_url in the payload. GET this URL (with your API key) to retrieve the workflow output and all artifacts with signed download URLs:
{ "run_id": "run-uuid", "status": "completed", "output": { "video_url": "https://..." }, "artifacts": [ { "asset_id": "asset-uuid", "filename": "ai-short.mp4", "content_type": "video/mp4", "download_url": "https://gofabric.dev/v1/assets/asset-uuid?expires=...&signature=...", "download_url_expires_at": "2026-04-19T12:00:00Z" } ]}Each download_url is a time-limited signed URL (default 1 hour, configurable via ?expires_in= on the output URL, max 24 hours). No auth headers needed for the download itself.
The SDKs provide convenience methods to handle this automatically:
// Option 1: submit + wait + get output with signed URLs in one callconst result = await fabric.workflows.runs.submitAndGetOutput("video/ai-shorts", { input: { topic: "AI news" },});for (const a of result.artifacts) { console.log(a.filename, "→", a.download_url);}
// Option 2: download artifact contents directlyconst files = await fabric.workflows.runs.downloadAllArtifacts(runId);for (const f of files) { fs.writeFileSync(f.filename, Buffer.from(f.data));}// Submit, wait, and get output with signed URLslet output = client.run_workflow_and_get_output( "video/ai-shorts", json!({ "topic": "AI news" }), None, // default 1h signed URL TTL).await?;
// Download artifact binary contentif let Some(artifacts) = output["artifacts"].as_array() { for a in artifacts { if let Some(url) = a["download_url"].as_str() { let bytes = client.download_artifact(url).await?; std::fs::write(a["filename"].as_str().unwrap(), &bytes)?; } }}Important: Workflow-generated assets are automatically cleaned up after 72 hours. Download or mirror assets promptly after receiving the completion event. Fabric is not intended as permanent storage — treat it as a transient delivery mechanism.
Run State Echo
Section titled “Run State Echo”When you submit a workflow run with a state field, that opaque JSON object is persisted on the run record and echoed back inside payload.state on all terminal events (workflow.run.completed, workflow.run.failed, workflow.run.cancelled). This lets multi-tenant consumers correlate completions with their own context (e.g. workspace ID, triggering user) without making a follow-up API call.
# Submit with statecurl -X POST https://gofabric.dev/v1/workflows/run?name=research/trend-analyst \ -H "Authorization: Bearer fab_xxx" \ -H "Content-Type: application/json" \ -d '{ "input": { "topic": "AI trends" }, "state": { "workspace_id": "ws-123", "triggered_by": "user-456" } }'The state is also visible in the run’s SSE/WebSocket event stream and in the GET /v1/workflows/runs/{id} response.
Headers
Section titled “Headers”Every delivery includes these headers:
| Header | Description |
|---|---|
Content-Type | application/json |
X-Fabric-Signature | sha256=<hex> — HMAC-SHA256 of the raw body |
X-Fabric-Event | Event kind (e.g. workflow.run.completed) |
X-Fabric-Delivery | Unique delivery/event ID |
X-Fabric-Retry | Attempt number (present on retries only) |
Signature Verification
Section titled “Signature Verification”Every delivery is signed with HMAC-SHA256 using the secret you provided at registration. Always verify signatures before processing payloads.
import { constructWebhookEvent, verifyWebhookSignature } from "@fabric-platform/sdk";
// Option 1: verify + parse in one call (recommended)app.post("/webhook", async (req, res) => { try { const event = await constructWebhookEvent( req.body, // raw body string req.headers["x-fabric-signature"]!, // sha256=<hex> process.env.WEBHOOK_SECRET!, );
switch (event.kind) { case "workflow.run.completed": const artifacts = await fabric.workflows.runs.artifactsWithUrls(event.run_id!); await processOutput(artifacts); break; case "workflow.run.failed": await alertOncall(event.payload); break; }
res.sendStatus(200); } catch { res.status(401).send("Invalid signature"); }});
// Option 2: verify separatelyconst valid = await verifyWebhookSignature(rawBody, signature, secret);from fabric_platform.webhooks import construct_event, verify_signatureimport os
# Option 1: verify + parse in one call (recommended)@app.post("/webhook")def handle_webhook(request): try: event = construct_event( raw_body=request.body, signature=request.headers["x-fabric-signature"], secret=os.environ["WEBHOOK_SECRET"], ) except ValueError: return Response(status=401)
if event.kind == "workflow.run.completed": process_output(event.run_id, event.payload) elif event.kind == "workflow.run.failed": alert_oncall(event.payload)
return Response(status=200)
# Option 2: verify separatelyvalid = verify_signature(raw_body, signature, secret)# Manual verification (e.g. in a shell script receiver):EXPECTED=$(echo -n "$RAW_BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | sed 's/.*= //')HEADER_HEX=$(echo "$X_FABRIC_SIGNATURE" | sed 's/^sha256=//')
if [ "$EXPECTED" = "$HEADER_HEX" ]; then echo "Signature valid"fiConsumer State
Section titled “Consumer State”Webhooks support an opaque state object (up to 16 KB) that is echoed back in every delivery. This is separate from the per-run state (submitted with the workflow) — consumer state lives on the webhook subscription itself and is included in every delivery regardless of which run triggered it.
Use this to attach your own context — workspace IDs, environment tags, external correlation IDs — so your receiver can route events without a database lookup.
const result = await fabric.webhooks.create(orgId, { url: "https://your-app.com/fabric/webhook", event_filter: ["workflow.run.completed", "workflow.run.failed"], state: { workspace_id: "ws-123", env: "production", callback_queue: "content-pipeline", },});curl -X POST https://gofabric.dev/v1/organizations/$ORG_ID/webhooks \ -H "Authorization: Bearer fab_xxx" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-app.com/fabric/webhook", "event_filter": ["workflow.run.completed", "workflow.run.failed"], "state": { "workspace_id": "ws-123", "env": "production" } }'When consumer state is set, deliveries wrap the event in an envelope with both the event and the state:
{ "event": { "id": "evt_...", "kind": "workflow.run.completed", "run_id": "run-uuid", "payload": { "output": { "..." : "..." } } }, "state": { "workspace_id": "ws-123", "env": "production", "callback_queue": "content-pipeline" }}Without consumer state, the raw event is delivered directly (no wrapping envelope).
Consumer state can be updated via PATCH /v1/webhooks/{id} without re-creating the webhook.
Resource Filtering
Section titled “Resource Filtering”Beyond event type filtering, you can scope a webhook to specific resources using resource_filter. All specified keys must match (AND logic):
{ "url": "https://your-app.com/webhook", "secret": "whsec_...", "event_filter": ["workflow.run.completed", "workflow.run.failed"], "resource_filter": { "workflow_definition_id": "wf-uuid-here" }}Supported filter keys:
| Key | Description |
|---|---|
workflow_definition_id | Only events for runs of this workflow definition |
workflow_run_id | Only events for this specific run |
job_id | Only events for this specific job |
An empty or absent resource_filter matches all events of the subscribed types.
Delivery & Retries
Section titled “Delivery & Retries”Fabric delivers webhooks with at-least-once semantics:
- Timeout: 10 seconds per attempt. Your endpoint must respond with a 2xx status within this window.
- Retries: Configurable via
max_retries(default 3). Failed deliveries retry with exponential backoff: 30s, 60s, 120s, 240s, … (30s × 2attempt, capped at ~8.5 hours). - Dead letter: After exhausting all retries, a
webhook.delivery.exhaustedevent is emitted. Subscribe to this event on a separate webhook to build alerting. - Idempotency: Use the
X-Fabric-Deliveryheader (event ID) to deduplicate — the same event may be delivered more than once on retries.
Inspecting Deliveries
Section titled “Inspecting Deliveries”const deliveries = await fabric.webhooks.deliveries(webhookId);for (const d of deliveries) { console.log(d.event_kind, d.status, d.status_code, d.attempt, d.error_message);}deliveries = fabric.list_webhook_deliveries(webhook_id)for d in deliveries: print(d["event_kind"], d["status"], d["status_code"], d["attempt"])curl https://gofabric.dev/v1/webhooks/$WEBHOOK_ID/deliveries \ -H "Authorization: Bearer fab_xxx"Each delivery record includes:
| Field | Description |
|---|---|
id | Unique delivery ID |
webhook_id | The webhook subscription this delivery belongs to |
event_id | The domain event ID |
event_kind | Event type string |
status | delivered, failed, or pending |
status_code | HTTP response code (if received) |
error_message | Error details (on failure) |
attempt | Current attempt number |
created_at | When the delivery was first attempted |
Managing Webhooks
Section titled “Managing Webhooks”CRUD Operations
Section titled “CRUD Operations”// List all webhooks for an orgconst webhooks = await fabric.webhooks.list(orgId);
// Get a single webhookconst wh = await fabric.webhooks.get(webhookId);
// Update (pause, change filters, etc.)await fabric.webhooks.update(webhookId, { active: false, description: "Paused for maintenance",});
// Re-activate with new filtersawait fabric.webhooks.update(webhookId, { active: true, event_filter: ["workflow.run.completed"], resource_filter: { workflow_definition_id: "wf-uuid" },});
// Deleteawait fabric.webhooks.delete(webhookId);# Listwebhooks = fabric.list_webhooks(org_id)
# Getwh = fabric.get_webhook(webhook_id)
# Updatefabric.update_webhook(webhook_id, active=False, description="Paused")
# Deletefabric.delete_webhook(webhook_id)# Listcurl https://gofabric.dev/v1/organizations/$ORG_ID/webhooks \ -H "Authorization: Bearer fab_xxx"
# Getcurl https://gofabric.dev/v1/webhooks/$WEBHOOK_ID \ -H "Authorization: Bearer fab_xxx"
# Updatecurl -X PATCH https://gofabric.dev/v1/webhooks/$WEBHOOK_ID \ -H "Authorization: Bearer fab_xxx" \ -H "Content-Type: application/json" \ -d '{ "active": false }'
# Deletecurl -X DELETE https://gofabric.dev/v1/webhooks/$WEBHOOK_ID \ -H "Authorization: Bearer fab_xxx"Secret Lifecycle
Section titled “Secret Lifecycle”Webhook secrets follow the Stripe pattern — the secret is generated server-side and returned once at creation time. It cannot be retrieved again via API.
How secrets work:
- Generation — When you create a webhook, Fabric generates a cryptographically secure 32-byte random key, formatted as
whsec_<64-hex-chars>. - One-time return — The
secretfield appears only in the create response. Store it immediately in a secure location (environment variable, secrets manager, encrypted database column). - Signing — On every delivery, Fabric computes
HMAC-SHA256(secret, raw_json_body)and sends the result asX-Fabric-Signature: sha256=<hex>. - Verification — Your receiver recomputes the HMAC with the stored secret and compares using constant-time comparison to prevent timing attacks.
If you lose the secret:
The secret cannot be recovered. Delete the webhook and create a new one — Fabric issues a fresh secret. Update your verification code with the new value.
Secret Rotation
Section titled “Secret Rotation”To rotate a webhook secret, delete the existing webhook and create a new one with a fresh secret. The old webhook stops receiving deliveries immediately.
API Endpoints Summary
Section titled “API Endpoints Summary”| Method | Path | Description |
|---|---|---|
POST | /v1/organizations/{org_id}/webhooks | Create a webhook |
GET | /v1/organizations/{org_id}/webhooks | List webhooks for an org |
GET | /v1/webhooks/{id} | Get a webhook |
PATCH | /v1/webhooks/{id} | Update a webhook |
DELETE | /v1/webhooks/{id} | Delete a webhook (soft) |
GET | /v1/webhooks/{id}/deliveries | List delivery attempts |
Best Practices
Section titled “Best Practices”-
Always verify signatures. Never trust a webhook payload without HMAC verification. Use
constructWebhookEvent()/construct_event()which combines verification and parsing. -
Respond quickly. Return a 2xx within the 10-second timeout. If you need to do heavy processing, enqueue the work and respond immediately.
-
Handle duplicates. Use the
X-Fabric-Deliveryevent ID to deduplicate. At-least-once delivery means you may receive the same event more than once. -
Use event filtering. Subscribe only to the events you need. This reduces delivery volume and avoids unnecessary processing.
-
Monitor delivery health. Check the delivery log for persistent failures. Subscribe to
webhook.delivery.exhaustedon a separate, reliable endpoint to catch dead letters. -
Rotate secrets periodically. Delete the existing webhook and create a new one with a fresh secret. Update your verification code with the new secret.
-
Use resource filters for multi-tenant setups. If you run multiple workflows, scope webhooks with
resource_filterto avoid cross-workflow noise. -
Download artifacts promptly. Workflow-generated assets expire after 72 hours (signed download URLs expire after 1 hour). Mirror artifacts to your own storage on
workflow.run.completed— don’t defer. See Asset Lifecycle for details. -
Use consumer state for multi-tenant routing. Attach a
stateobject (workspace ID, environment, callback queue) to the webhook so your receiver can route events without a database lookup.