Fibric. Docs fibric.io →
v0.9 · preview
Guides

Receive events with webhooks

Fibric can push to you as well as be polled. Webhook endpoints receive plan proposals, action dispositions, and connector status changes as they happen, each delivery signed with an HMAC in the Fibric-Signature header. This guide registers an endpoint, explains the delivery contract, and gives you a Node handler that verifies signatures with a timing-safe compare.

!
Webhook management is a preview surface

The webhook management endpoints (whk_ ids) are being reworked for the v1 freeze. Everything on this page, the CLI verbs and the preview HTTP endpoints, works today, but the management surface may change before v1 and is not covered by the API's versioning guarantees. The delivery contract itself, the payload shapes, the signature scheme, and the retry semantics, is stable. See the webhooks note on the API overview.

What gets delivered

Deliveries carry the same objects the pull APIs define; a webhook is a push channel, not a different data model. Each delivery wraps exactly one object in a per-event delivery envelope, described below.

Delivery typeFires whenPayload object
plan.proposedAn operator proposes an execution plan.The plan object from the Actions & plans API.
plan.awaiting_approvalA plan's policy evaluation is ALERT and it is parked for a human.The plan object, with its pending disposition.
action.disposedThe executor disposes an action: applied, blocked, or deduped.The action object from the Actions & plans API.
connector.status_changedAn installed connector changes health, for example ready to degraded.The connector object from the Connectors API.

Note what is absent: raw event ingest is not fanned out over webhooks. If you want the event stream itself, tail it with fibric events tail or page through the Events API with cursor pagination; webhooks exist to tell you when Fibric decided or did something, not to mirror every envelope.

Prerequisites

Register an endpoint

Register with the CLI. You choose which delivery types the endpoint receives; an endpoint subscribed to nothing receives nothing, and you can add types later without re-registering.

bash
# register an endpoint for plan and action deliveries
fibric webhooks add https://ops.example.com/fibric/hooks \
  --types plan.proposed,plan.awaiting_approval,action.disposed

# list registered endpoints
fibric webhooks ls
$ fibric webhooks add https://ops.example.com/fibric/hooks --types plan.proposed,plan.awaiting_approval,action.disposed endpoint whk_4e1a92 url https://ops.example.com/fibric/hooks types plan.proposed, plan.awaiting_approval, action.disposed secret whsec_Zm9y…kJ2c (shown once, store it now) ✓ endpoint registered
i
The secret is shown once

The whsec_ signing secret is displayed at registration and never again. Store it in your secret manager, not in code. If you lose it, rotate with fibric webhooks rotate whk_4e1a92, which issues a new secret and keeps the old one valid for 24 hours so you can deploy the change without dropping verifications.

The same registration is available over the preview HTTP endpoints. They live under the standard base URL and follow the API's conventions, including the Idempotency-Key header on POST, but as preview endpoints they sit outside the frozen surface:

bash · preview HTTP registration
curl https://api.fibric.io/preview/webhooks \
  -H "Authorization: Bearer $FIBRIC_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: register-ops-hooks" \
  -d '{
    "url": "https://ops.example.com/fibric/hooks",
    "types": ["plan.proposed", "plan.awaiting_approval", "action.disposed"]
  }'

Delivery semantics

The contract has three parts, and your handler should be written against all three.

Your endpoint acknowledges by returning any 2xx status within 10 seconds. Anything else, a non-2xx, a timeout, a connection failure, counts as a failed attempt and schedules a retry. Respond first and process afterward if your processing is slow; a queue on your side is cheaper than relying on retries.

The delivery payload

Each request body is a delivery envelope: identity and type at the top, the API object in data. Tenancy fields are present on every delivery, the same law that puts reseller_id and tenant_id on every event envelope and every row.

json · an action.disposed delivery
{
  "delivery_id": "whd_7f3b21",
  "object": "webhook_delivery",
  "type": "action.disposed",
  "reseller_id": null,
  "tenant_id": "t_8f2ac901",
  "created_at": "2026-07-02T16:02:44Z",
  "data": {
    "id": "ac_2d91e0",
    "object": "action",
    "plan_id": "pl_7c1a",
    "connector": "kustomer",
    "tool": "calls.status-sync",
    "entity_key": "contact:7c1f03ba",
    "idempotency_key": "amazon-connect:7c1f03ba:status-sync",
    "disposition": "applied",
    "receipt_id": "rc_9e12",
    "correlation_id": "co_7c1f03",
    "created_at": "2026-07-02T16:02:43Z"
  }
}

The object in data is exactly what GET /actions/{action_id} would return; nothing exists in a webhook that you cannot also fetch. Follow receipt_id into the receipt ledger for the full proposal-to-disposition record.

Retries and backoff

Failed deliveries retry on an exponential backoff schedule with jitter, up to eight attempts over roughly 21 hours. After the final failure the delivery is marked dead and the endpoint's failure count rises; an endpoint that only fails for 24 hours is automatically disabled, and you re-enable it after fixing your side.

AttemptDelay after previous failureElapsed, approximate
1immediate0
230 seconds30 s
32 minutes2.5 min
410 minutes13 min
530 minutes43 min
62 hours2.7 h
76 hours8.7 h
812 hours20.7 h

Each delay carries up to 20 percent random jitter so a fleet of failed deliveries does not retry in lockstep. Retries reuse the same delivery_id and the same body; only the signature timestamp changes, because each attempt is signed fresh.

Verify signatures

Every delivery carries a Fibric-Signature header. Verify it before trusting the body; an unverified webhook endpoint is an unauthenticated write API into your systems.

http · the signature header
Fibric-Signature: t=1751472164,v1=5f8b2c19e4a7d3…9c01

The format is two comma-separated pairs: t, the Unix timestamp (seconds) of the signing moment, and v1, a lowercase hex HMAC-SHA256 of the string ${t}.${body}, keyed with your whsec_ secret, where body is the raw request body, byte for byte, before any JSON parsing. To verify:

  1. Parse t and v1 out of the header.
  2. Reject if t is more than five minutes from your clock; this bounds replay of a captured delivery.
  3. Compute HMAC-SHA256 over ${t}.${body} with your secret and compare against v1 using a constant-time comparison, never ==.
!
Sign the raw body, not the parsed body

The HMAC covers the exact bytes on the wire. If your framework parses JSON before your handler runs, re-serializing the object will almost never reproduce those bytes, and verification will fail intermittently. Capture the raw body, verify, then parse. The express example below does this with the verify hook.

Example handler, Node and express

handler.ts
import crypto from "node:crypto";
import express from "express";

const SECRET = process.env.FIBRIC_WEBHOOK_SECRET!; // the whsec_… value
const TOLERANCE_SECONDS = 300;

function verifySignature(header: string | undefined, rawBody: Buffer): boolean {
  if (!header) return false;

  const parts = Object.fromEntries(
    header.split(",").map((p) => p.split("=", 2) as [string, string]),
  );
  const t = Number(parts["t"]);
  const v1 = parts["v1"];
  if (!Number.isFinite(t) || !v1) return false;

  // bound replay: reject stale or future timestamps
  if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;

  // hmac-sha256 over `${t}.${body}`, hex-encoded
  const expected = crypto
    .createHmac("sha256", SECRET)
    .update(`${t}.`)
    .update(rawBody)
    .digest("hex");

  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(v1, "hex");
  // constant-time compare; length check first, timingSafeEqual throws on mismatch
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

// keep the raw bytes: the HMAC covers the wire body, not re-serialized JSON
app.use(express.json({
  verify: (req, _res, buf) => { (req as any).rawBody = buf; },
}));

app.post("/fibric/hooks", (req, res) => {
  const ok = verifySignature(
    req.header("Fibric-Signature"),
    (req as any).rawBody,
  );
  if (!ok) return res.status(401).end();

  const delivery = req.body;

  // at-least-once: drop duplicates by delivery_id before doing work
  if (alreadySeen(delivery.delivery_id)) return res.status(200).end();

  // acknowledge fast, process after; retries are not a work queue
  enqueue(delivery);
  res.status(200).end();
});

app.listen(8080);

alreadySeen and enqueue are yours to implement: a Redis set with a 48-hour TTL and any job queue will do. The shape matters more than the parts, verify, deduplicate, acknowledge, then work.

Send a test delivery

The CLI can fire a signed test delivery at a registered endpoint, so you can confirm verification end to end before real traffic depends on it:

bash
fibric webhooks test whk_4e1a92 --type action.disposed
$ fibric webhooks test whk_4e1a92 --type action.disposed delivery whd_test_01 type=action.disposed signed t=1751472164 · v1=5f8b2c19…9c01 response 200 in 84ms ✓ endpoint verified the signature and acknowledged

Recent deliveries and their attempt history are visible per endpoint with fibric webhooks deliveries whk_4e1a92, including the response code your endpoint returned on each attempt.

Troubleshooting

SymptomLikely causeFix
Verification fails intermittentlyYou are HMAC-ing a re-serialized body instead of the raw bytes.Capture the raw body before JSON parsing, as in the express verify hook above.
Verification always failsWrong secret, often after a rotation, or the ${t}. prefix is missing from the signed string.Confirm the whsec_ value in your secret manager and that you concatenate timestamp, a dot, then the body.
Deliveries rejected with your own 401 after deploysClock skew on the new host trips the timestamp tolerance.Sync NTP; keep the tolerance at five minutes rather than tightening it.
Duplicate processing downstreamHandler does work before acknowledging, so a timeout triggers a retry of work that already ran.Deduplicate on delivery_id and acknowledge before processing.
Endpoint disabled automaticallyIt failed every attempt for 24 hours.Fix the endpoint, then re-enable with fibric webhooks enable whk_4e1a92. Dead deliveries are not replayed automatically; fetch the missed window from the pull APIs.

Next steps