Verify webhook signatures

Check the X-Bilify-Signature header so your endpoint only accepts genuine, fresh deliveries. Ready-to-use code for PHP, Node.js and Python.

4 min read Updated 04.10.2026 Requires: REST API & webhooks

Anyone who knows your webhook URL can send it a request. Every genuine Bilify delivery is signed with your endpoint's signing secret, so your receiver can prove a request came from Bilify, was not changed on the way, and is not an old request being replayed. Do this check on every delivery before you act on it.

The signature header

Each delivery carries one header:

X-Bilify-Signature: t=1791100800,v1=b5600c284432b9dfcb76fb21f58d9d0e044986b635576e2826d8ab096a0aacfa
Part Meaning
t When Bilify signed the delivery, in Unix seconds
v1 HMAC-SHA256 of the signed material, as lowercase hexadecimal

The signed material is the timestamp, a full stop, and the raw request body, exactly as received:

<t>.<raw body>

The key is your endpoint's signing secret, the whole value including the whsec_ prefix. You saw it once when you created the endpoint; if you no longer have it, use Regenerate secret in the endpoint's menu (see Webhooks).

How to verify

  1. Read the raw body bytes before any JSON parsing. Re-encoding parsed JSON changes spacing and escaping and breaks the signature.
  2. Split the header on , and each part on the first =. Take t (digits only) and the v1 value(s).
  3. Reject the request if t is more than 300 seconds (5 minutes) away from your server's clock, in either direction. Keep your server's clock synchronised (NTP).
  4. Compute HMAC-SHA256(secret, t + "." + rawBody) as lowercase hex.
  5. Compare it with v1 using a constant-time comparison. Never use == on strings for this.
  6. If it matches, answer 2xx quickly and process the event. If not, answer 400 and ignore the body.

Test vector

With the secret whsec_example, t=1791100800 and the body {"event":"client.created"} (no spaces, no newline), the expected v1 is b5600c284432b9dfcb76fb21f58d9d0e044986b635576e2826d8ab096a0aacfa. Use it to check your code with the tolerance check switched off.

PHP

<?php

function bilify_verify(string $payload, string $header, string $secret, int $tolerance = 300): bool
{
    $timestamp = null;
    $signatures = [];

    foreach (explode(',', $header) as $part) {
        [$key, $value] = array_pad(explode('=', trim($part), 2), 2, null);

        if ($key === 't' && $value !== null && ctype_digit($value)) {
            $timestamp = (int) $value;
        } elseif ($key === 'v1' && $value !== null && $value !== '') {
            $signatures[] = $value;
        }
    }

    if ($timestamp === null || $signatures === []) {
        return false;
    }

    if (abs(time() - $timestamp) > $tolerance) {
        return false;
    }

    $expected = hash_hmac('sha256', $timestamp.'.'.$payload, $secret);

    foreach ($signatures as $signature) {
        if (hash_equals($expected, $signature)) {
            return true;
        }
    }

    return false;
}

// Plain PHP endpoint
$payload = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_BILIFY_SIGNATURE'] ?? '';

if (! bilify_verify($payload, $header, getenv('BILIFY_WEBHOOK_SECRET'))) {
    http_response_code(400);
    exit;
}

$event = json_decode($payload, true);
http_response_code(200);
// Queue $event['event'] / $event['data'] for processing here.

In Laravel, pass $request->getContent() as the payload and $request->header('X-Bilify-Signature', '') as the header, and exclude the route from CSRF protection.

Node.js (Express)

const crypto = require('crypto');
const express = require('express');

function verifyBilify(rawBody, header, secret, toleranceSeconds = 300) {
  if (!header) return false;

  let timestamp = null;
  const signatures = [];

  for (const part of header.split(',')) {
    const index = part.indexOf('=');
    if (index === -1) continue;
    const key = part.slice(0, index).trim();
    const value = part.slice(index + 1).trim();

    if (key === 't' && /^\d+$/.test(value)) timestamp = parseInt(value, 10);
    else if (key === 'v1' && value) signatures.push(value);
  }

  if (timestamp === null || signatures.length === 0) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSeconds) return false;

  const expected = Buffer.from(
    crypto.createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest('hex'),
    'utf8'
  );

  return signatures.some((signature) => {
    const given = Buffer.from(signature, 'utf8');
    return given.length === expected.length && crypto.timingSafeEqual(given, expected);
  });
}

const app = express();

// express.raw keeps the body as a Buffer: do not use express.json() on this route.
app.post('/webhooks/bilify', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verifyBilify(req.body, req.get('X-Bilify-Signature'), process.env.BILIFY_WEBHOOK_SECRET)) {
    return res.sendStatus(400);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  res.sendStatus(200);
  // Process event.event / event.data here.
});

app.listen(3000);

Python (Flask)

import hashlib
import hmac
import os
import time

from flask import Flask, abort, request


def verify_bilify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    if not header:
        return False
    timestamp = None
    signatures = []
    for part in header.split(","):
        key, sep, value = part.strip().partition("=")
        if not sep:
            continue
        if key == "t" and value.isdigit():
            timestamp = int(value)
        elif key == "v1" and value:
            signatures.append(value)
    if timestamp is None or not signatures:
        return False
    if abs(int(time.time()) - timestamp) > tolerance:
        return False
    signed = str(timestamp).encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, s) for s in signatures)


app = Flask(__name__)


@app.post("/webhooks/bilify")
def bilify_webhook():
    raw = request.get_data()  # raw bytes, read before any JSON parsing
    if not verify_bilify(raw, request.headers.get("X-Bilify-Signature", ""), os.environ["BILIFY_WEBHOOK_SECRET"]):
        abort(400)
    event = request.get_json()
    # Process event["event"] / event["data"] here.
    return "", 200

Common mistakes

Symptom Cause
Every signature fails The body was parsed and re-serialised before hashing, or a framework added a newline. Hash the raw bytes.
Every signature fails after you regenerated the secret The receiver still has the old secret. Regenerating takes effect immediately.
Signature fails only on some deliveries Your server's clock drifted more than 5 minutes, or a proxy changes the body (for example re-encoding characters).
Only redelivered events fail Redeliveries are signed again with a new t and the current secret, so check the clock and the secret, not the original timestamp.
Secret seems right but HMAC differs Use the whole secret including whsec_, as text, not base64-decoded.