Skip to main content

Handle webhooks

Webhooks let you skip the polling loop. Register an HTTPS endpoint, and FlowBeacon will POST you 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" }
}
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 + an encrypted signature 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')));
});
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.