Verifying signatures
Anyone can POST JSON to your webhook URL. Always verify the signature before you trust a payload — it proves the request came from Billey and was not tampered with in transit. This page has copy-pasteable, runnable examples in PHP and Node.js.
What Billey signs
For each delivery Billey computes:
signature = HMAC_SHA256( "{X-Billey-Timestamp}.{raw request body}", signing_secret )
The signed string is the X-Billey-Timestamp header value, a literal dot (.), then the raw request body bytes — in that exact order. The result is sent hex-encoded in the X-Billey-Signature header as v1=<hex>.
Your signing secret (whsec_…) is shown once when you create the subscription; load it from configuration, never hard-code it.
The six steps a receiver must implement
- Read the raw body bytes. Do not parse the JSON and re-serialize it — different whitespace or key order produces different bytes and the HMAC will not match. Capture the body exactly as received.
- Read the headers
X-Billey-TimestampandX-Billey-Signature. The signature has the formv1=<hex>; take the hex afterv1=. - Recompute
HMAC_SHA256("{timestamp}.{rawBody}", secret)with your signing secret. - Compare in constant time. Use
hash_equals(PHP) orcrypto.timingSafeEqual(Node) — never==/===/!==, which leak timing information about a secret comparison. - Reject stale deliveries: if
abs(now - timestamp) > 300seconds, refuse it. This bounds how long a captured request can be replayed. - Deduplicate and route on the signed body. Use
X-Billey-Event-Id(which equals the bodyid) as an idempotency key so a retry or manual replay is never applied twice, and read the event kind from the signed bodytype, never the unsignedX-Billey-Event-Typeheader.
Only if the signature matches and the timestamp is fresh should you treat the payload as authentic.
PHP
<?php
// Your subscription's signing secret. Load it from config/env — never commit it.
$secret = getenv('BILLEY_WEBHOOK_SECRET');
// 1. Read the RAW body — do not json_decode then re-encode.
$rawBody = file_get_contents('php://input');
// 2. Read the timestamp and signature headers.
$timestamp = $_SERVER['HTTP_X_BILLEY_TIMESTAMP'] ?? '';
$signatureHeader = $_SERVER['HTTP_X_BILLEY_SIGNATURE'] ?? '';
// The signature header is "v1=<hex>" — take the hex after "v1=".
if (! str_starts_with($signatureHeader, 'v1=')) {
http_response_code(400);
exit;
}
$received = substr($signatureHeader, 3);
// 3. Recompute the HMAC over "{timestamp}.{rawBody}".
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
// 4. Constant-time compare — never == or ===.
if (! hash_equals($expected, $received)) {
http_response_code(400);
exit;
}
// 5. Reject stale deliveries (300-second tolerance).
if (abs(time() - (int) $timestamp) > 300) {
http_response_code(400);
exit;
}
// Verified. Parse the SIGNED body and act on it.
$event = json_decode($rawBody, true);
// 6. Idempotency + route on the signed body, not the headers.
$eventId = $event['id']; // equals X-Billey-Event-Id
$eventType = $event['type']; // e.g. "invoice.paid" — trust this, not the header
$resource = $event['data']; // same shape as the REST GET for that resource
// if ($this->alreadyProcessed($eventId)) { http_response_code(200); exit; }
// ... handle $eventType using $resource ...
http_response_code(200);
Node.js (Express)
const crypto = require('crypto');
const express = require('express');
const app = express();
// Capture the RAW body bytes. express.json() would parse and discard them,
// which breaks the HMAC — so mount express.raw() on the webhook route.
app.post(
'/webhooks/billey',
express.raw({ type: 'application/json' }),
(req, res) => {
const secret = process.env.BILLEY_WEBHOOK_SECRET;
// 1. req.body is a Buffer here — the raw bytes, untouched.
const rawBody = req.body;
// 2. Read the headers.
const timestamp = req.get('X-Billey-Timestamp') || '';
const signatureHeader = req.get('X-Billey-Signature') || '';
if (!signatureHeader.startsWith('v1=')) {
return res.status(400).end();
}
const received = signatureHeader.slice(3);
// 3. Recompute the HMAC over "{timestamp}.{rawBody}" (build it as bytes).
const signedPayload = Buffer.concat([Buffer.from(`${timestamp}.`), rawBody]);
const expected = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
// 4. Constant-time compare — never === on the hex strings.
const a = Buffer.from(expected);
const b = Buffer.from(received);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(400).end();
}
// 5. Reject stale deliveries (300-second tolerance).
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(timestamp)) > 300) {
return res.status(400).end();
}
// Verified. Parse the SIGNED body.
const event = JSON.parse(rawBody.toString('utf8'));
// 6. Idempotency + route on the signed body, not the headers.
const eventId = event.id; // equals X-Billey-Event-Id
const eventType = event.type; // e.g. "invoice.paid" — trust this, not the header
const resource = event.data; // same shape as the REST GET
// if (alreadyProcessed(eventId)) return res.status(200).end();
// ... handle eventType using resource ...
res.status(200).end();
},
);
app.listen(3000);
Detecting tampering (optional)
The signed body is authoritative. If you want to actively detect in-transit tampering, you may treat an X-Billey-Event-Type header that disagrees with the signed body type (or an X-Billey-Event-Id header that disagrees with the body id) as a tamper signal and reject — but the signed body always wins, so verifying the signature is what actually protects you.