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.completein near-real-time. - You run a multi-tenant integrator and don't want to drive every poll loop yourself.
Supported events (v1)
| Event | Fires when | Payload |
|---|---|---|
evaluation.complete | A 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:
| Header | Example | Meaning |
|---|---|---|
X-FB-Event | evaluation.complete | Event name. Check against the subscription's registered list. |
X-FB-Signature | t=1714564800,v1=9f86... | Timestamp + HMAC-SHA-256 over {ts}.{raw_body}. |
User-Agent | FlowBeacon-Webhook/1.0 | Stable 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.
- TypeScript (Express)
- Python (FastAPI)
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')));
});
import hashlib, hmac, os, time
from fastapi import FastAPI, Header, HTTPException, Request
app = FastAPI()
@app.post("/flowbeacon")
async def webhook(req: Request, x_fb_signature: str = Header(default="")):
raw = await req.body() # raw bytes — do not parse first
try:
parts = dict(p.split("=") for p in x_fb_signature.split(","))
ts = int(parts["t"])
except Exception:
raise HTTPException(403, "bad header")
if abs(time.time() - ts) > 300:
raise HTTPException(403, "stale")
expected = hmac.new(
os.environ["FB_SIGNING_SECRET"].encode(),
f"{ts}.{raw.decode()}".encode(),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(parts.get("v1", ""), expected):
raise HTTPException(403, "bad signature")
# ACK now; process async.
enqueue(raw)
return {"ok": True}
:::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_idas your dedupe key. A simple in-memory LRU or aseen_eventstable 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
| Aspect | Behaviour |
|---|---|
| Retries | Up to 3 attempts per delivery with the same signed payload. |
| Per-attempt timeout | 10 seconds. |
| Backoff | Built-in. Not configurable. |
| ACK | Any 2xx response ends the delivery. |
| Quarantine | None today. Operators monitor delivery_failure_count via GET /webhooks. |
Operations
GET /webhooks— list every subscription owned by the calling organization, including revoked ones. Returnslast_delivery_at,last_delivery_status,delivery_failure_count.DELETE /webhooks/{id}— revoke immediately. No further deliveries fire.POST /webhooks/{id}/rotate-secret— returns a newsigning_secret. The previous one stops working instantly. Resetdelivery_failure_countto 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
- Rate limits and retries — the limits that apply to your registration calls.
- Result tokens — the alternate single-fetch path.
- API reference → Webhooks — every field, every endpoint.