Skip to main content

Handle webhooks

Webhooks let you skip the polling loop. Register an HTTPS endpoint, and FlowBeacon will POST you HMAC-signed event payloads as soon as they happen.

When to use this

  • You ship a production integration and want to scale beyond polling.
  • You need to react to evaluation.complete in near-real-time.
  • You run a multi-tenant integrator and don't want to drive every poll loop yourself.

Supported events (v1)

EventFires whenPayload
evaluation.completeA batch evaluation transitions to complete.{ event, evaluation_id, organization_id, scenarios[] }

Each scenarios[] entry carries the same violations[], module_summary[], evaluated_codes[], passing_count, failing_count fields as GET /scenarios/{id}/results.

Future events will be announced in the Changelog. A subscription only receives the events it opted into.

Step 1 — Register a subscription

POST /api/public/v1/webhooks
Authorization: Bearer fb_live_EXAMPLE_xxxxxxxxxxxxxxxxxxxx
X-FB-Signature: t=1714564800,v1=...
Content-Type: application/json

{
"url": "https://hooks.example.com/flowbeacon",
"events": ["evaluation.complete"],
"description": "Production handler"
}

Response (200):

{
"ok": true,
"data": {
"id": "sub_example_01HZY...",
"url": "https://hooks.example.com/flowbeacon",
"events": ["evaluation.complete"],
"description": "Production handler",
"is_active": true,
"signing_secret": "whsec_EXAMPLE_abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdef",
"warning": "Store this signing_secret securely — it will not be shown again."
},
"meta": { "watermark": "wm_a1b2c3d4" }
}

:::caution signing_secret is shown once Store it somewhere durable. If it leaks, call POST /webhooks/{id}/rotate-secret — the previous secret stops working immediately, with no grace period. :::

Step 2 — Verify deliveries

Every webhook POST from FlowBeacon carries:

HeaderExampleMeaning
X-FB-Eventevaluation.completeEvent name. Check against the subscription's registered list.
X-FB-Signaturet=1714564800,v1=9f86...Timestamp + HMAC-SHA-256 over {ts}.{raw_body}.
User-AgentFlowBeacon-Webhook/1.0Stable identifier.

Canonical signed string for webhooks:

message = "{timestamp}.{raw_request_body}"

Note: the webhook signature covers timestamp + body only (no method, no path). The receiver controls the URL, so signing it would be redundant. Tolerance window is 300 seconds.

The signing key is the subscription's signing_secret, not the API key.

import crypto from 'node:crypto';
import express from 'express';

const app = express();

// IMPORTANT: capture the raw body BEFORE any JSON middleware.
app.use(express.raw({type: 'application/json'}));

app.post('/flowbeacon', (req, res) => {
const header = req.header('x-fb-signature') ?? '';
const pairs = header
.split(',')
.map((p) => p.split('='))
.filter((pair) => pair.length === 2);
const parts = Object.fromEntries(pairs);
const ts = Number(parts.t);
if (!ts || Math.abs(Date.now() / 1000 - ts) > 300) {
return res.status(403).end('stale or missing timestamp');
}
const expected = crypto
.createHmac('sha256', process.env.FB_SIGNING_SECRET!)
.update(`${ts}.${req.body.toString('utf8')}`)
.digest('hex');
const actualBuf = Buffer.from(parts.v1 ?? '', 'hex');
const expectedBuf = Buffer.from(expected, 'hex');
const ok =
actualBuf.length === expectedBuf.length &&
crypto.timingSafeEqual(actualBuf, expectedBuf);
if (!ok) return res.status(403).end('bad signature');

// ACK immediately. Process async.
res.status(200).end();
void process(JSON.parse(req.body.toString('utf8')));
});

:::tip Always verify on raw bytes Verify the raw request body bytes, before any JSON parsing or middleware reformatting. JSON re-serialisation will change byte order, whitespace, or numeric formatting and break the signature. :::

Step 3 — Be idempotent

  • At-least-once delivery. A 2xx response is taken as acknowledged. Brief duplicates are possible if the network confirms after timeout.
  • No ordering guarantees across subscriptions or events.
  • Use evaluation_id as your dedupe key. A simple in-memory LRU or a seen_events table works fine.
async function process(payload: {evaluation_id: string}) {
if (await seen(payload.evaluation_id)) return;
await markSeen(payload.evaluation_id);
await doRealWork(payload);
}

Delivery semantics

AspectBehaviour
RetriesUp to 3 attempts per delivery with the same signed payload.
Per-attempt timeout10 seconds.
BackoffBuilt-in. Not configurable.
ACKAny 2xx response ends the delivery.
QuarantineNone today. Operators monitor delivery_failure_count via GET /webhooks.

Operations

  • GET /webhooks — list every subscription owned by the calling organization, including revoked ones. Returns last_delivery_at, last_delivery_status, delivery_failure_count.
  • DELETE /webhooks/{id} — revoke immediately. No further deliveries fire.
  • POST /webhooks/{id}/rotate-secret — returns a new signing_secret. The previous one stops working instantly. Reset delivery_failure_count to zero.

Local development

http:// URLs are accepted only for localhost, 127.0.0.1, and [::1]. Use a tunnel (e.g. ngrok, cloudflared) to expose your local handler to a public HTTPS URL while testing.

Next steps