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

  1. 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.
  2. Read the headers X-Billey-Timestamp and X-Billey-Signature. The signature has the form v1=<hex>; take the hex after v1=.
  3. Recompute HMAC_SHA256("{timestamp}.{rawBody}", secret) with your signing secret.
  4. Compare in constant time. Use hash_equals (PHP) or crypto.timingSafeEqual (Node) — never == / === / !==, which leak timing information about a secret comparison.
  5. Reject stale deliveries: if abs(now - timestamp) > 300 seconds, refuse it. This bounds how long a captured request can be replayed.
  6. Deduplicate and route on the signed body. Use X-Billey-Event-Id (which equals the body id) as an idempotency key so a retry or manual replay is never applied twice, and read the event kind from the signed body type, never the unsigned X-Billey-Event-Type header.

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.

Something inaccurate or missing? support@billey.nl