bildflowDocs

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.

We use cookies for analytics. Cookie Policy