Docs › Distribute and automate
Webhook payloads and signatures
What a webhook request contains, how to check its signature in Node or PHP, and how to handle repeats and events out of order.
Every webhook is a POST with a JSON body and the headers User-Agent: bildflow-webhooks/1, bildflow-event-type (for example lead.created), bildflow-event-id (the same as id in the body) and bildflow-signature: t=<unix time>,v1=<signature>.
The body
Every event has the same envelope, with the event's details under data. A lead.created event:
{
"id": "evt_0e9b1c2d-3a4f-4b5c-8d6e-7f8a9b0c1d2e", "type": "lead.created", "api_version": "2026-10-07",
"created_at": "2026-10-07T09:30:00.000Z", "workspace": { "id": "5d0c1b2a-3e4f-4a5b-8c6d-7e8f9a0b1c2d" },
"data": {
"id": "1b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e", "name": "Eleni Example", "email": "eleni@example.com",
"phone": "+357 99 000000", "source": "website", "language": "en", "timeline": "3m", "purpose": "home",
"bedrooms_min": 2, "budget": 350000, "created_at": "2026-10-07T09:30:00.000Z",
"project": { "id": "7a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "name": "Harbour Views", "slug": "harbour-views" },
"assigned_to": { "id": "2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f", "name": "Sam Seller" },
"attribution": { "embedded": true, "utm": { "utm_source": "newsletter", "utm_medium": "email",
"utm_campaign": "autumn-launch", "utm_content": null, "utm_term": null } }
}
}The fields of every event, with an example of each, are in the OpenAPI document at /api/public/openapi.json. See Integrations and API.
Check the signature
Compute the HMAC-SHA256 of <t>.<raw body> with your signing secret, as hex, and compare it with v1 (64 lowercase hex characters). Use the body exactly as it arrived, before parsing it (in PHP, file_get_contents('php://input')), and reject a t more than 5 minutes old.
const crypto = require('node:crypto')
function verify(secret, header, rawBody) {
if (typeof header !== 'string') return false
const p = new Map(header.split(',').map((part) => part.split('=')))
const t = p.get('t'), v1 = p.get('v1')
if (!/^\d{1,15}$/.test(t ?? '') || !/^[0-9a-f]{64}$/.test(v1 ?? '')) return false
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest()
return crypto.timingSafeEqual(expected, Buffer.from(v1, 'hex'))
}function verify(string $secret, string $header, string $rawBody): bool {
parse_str(str_replace(',', '&', $header), $p);
$t = $p['t'] ?? null; $v1 = $p['v1'] ?? null;
if (!is_string($t) || !is_string($v1) || !preg_match('/^[0-9]{1,15}$/D', $t) || !preg_match('/^[0-9a-f]{64}$/D', $v1)) return false;
return abs(time() - (int) $t) <= 300 && hash_equals(hash_hmac('sha256', $t . '.' . $rawBody, $secret), $v1);
}Repeats and order
Answer with a 2xx status quickly and do any slow work afterwards. Delivery is at least once: the same event can arrive twice, for example after a timeout. Keep the bildflow-event-id of each event you handle and skip one you've already seen. Events can also arrive out of order, so rely on created_at and the data, not on the order they arrive in.