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.
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
- Read the raw body bytes before any JSON parsing. Re-encoding parsed JSON changes spacing and escaping and breaks the signature.
- Split the header on
,and each part on the first=. Taket(digits only) and thev1value(s). - Reject the request if
tis more than 300 seconds (5 minutes) away from your server's clock, in either direction. Keep your server's clock synchronised (NTP). - Compute
HMAC-SHA256(secret, t + "." + rawBody)as lowercase hex. - Compare it with
v1using a constant-time comparison. Never use==on strings for this. - If it matches, answer
2xxquickly and process the event. If not, answer400and 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. |
Was this page helpful?
Related articles
Still stuck?
Write to us and we will get back to you within one working day.