Verify signatures
Protect your webhook endpoint using HMAC-SHA256 signature verification.
Every webhook delivery includes an x-signature header. Verifying it ensures the request genuinely came from Billium and that the payload was not modified in transit.
Never process a webhook payload without verifying its signature first.
Signature format
x-signature: t=1741406520,v1=a3f9e2b1c4d5...| Part | Description |
|---|---|
t | Unix timestamp in seconds of when the delivery was dispatched |
v1 | HMAC-SHA256 hex digest of "{t}.{raw_body}" using your webhook secret |
How verification works
- Parse the
x-signatureheader to extracttandv1. - Check that
tis within an acceptable window of the current time (default ±300 s) to protect against replay attacks. - Recompute the HMAC:
HMAC-SHA256(secret, "{t}.{raw_body}"). - Compare the result to
v1using a timing-safe comparison.
Use the raw request body — the exact bytes received over the wire. Parsing the JSON and re-serialising it will produce a different byte sequence and cause verification to fail.
Verify with @billium/node (recommended)
The SDK handles all the steps above for you:
import {
Billium,
BilliumWebhookSignatureError,
BilliumWebhookTimestampError,
} from '@billium/node';
const billium = new Billium({
webhookSecret: process.env.BILLIUM_WEBHOOK_SECRET,
});
try {
const event = billium.webhooks.verify(
rawBody, // Buffer | string — raw body, not parsed
req.headers['x-signature'], // x-signature header value
undefined, // secret — omit when set in constructor
{ tolerance: 300 }, // optional, default is 300 s
);
console.log(event.event); // 'invoice.paid'
} catch (err) {
if (err instanceof BilliumWebhookTimestampError) {
// timestamp outside the tolerance window — possible replay attack
}
if (err instanceof BilliumWebhookSignatureError) {
// header malformed or HMAC mismatch — reject the request
}
}Verify manually (any language)
If you are not using the Node.js SDK, here is the algorithm implemented in several languages:
import { createHmac, timingSafeEqual } from 'crypto';
function verifySignature(
rawBody: string | Buffer,
signatureHeader: string,
secret: string,
toleranceSeconds = 300,
): boolean {
const parts = Object.fromEntries(
signatureHeader.split(',').map((p) => p.split('=')),
);
const t = parseInt(parts['t'], 10);
const v1 = parts['v1'];
if (!t || !v1) throw new Error('Malformed x-signature header');
if (toleranceSeconds > 0) {
const diff = Math.abs(Math.floor(Date.now() / 1000) - t);
if (diff > toleranceSeconds) throw new Error('Timestamp out of tolerance');
}
const body = typeof rawBody === 'string' ? rawBody : rawBody.toString('utf8');
const expected = createHmac('sha256', secret)
.update(`${t}.${body}`)
.digest('hex');
const expectedBuf = Buffer.from(expected, 'hex');
const actualBuf = Buffer.from(v1, 'hex');
if (expectedBuf.length !== actualBuf.length) return false;
return timingSafeEqual(expectedBuf, actualBuf);
}import hashlib
import hmac
import time
def verify_signature(
raw_body: bytes,
signature_header: str,
secret: str,
tolerance: int = 300,
) -> bool:
parts = dict(p.split("=", 1) for p in signature_header.split(","))
t = int(parts.get("t", 0))
v1 = parts.get("v1", "")
if not t or not v1:
raise ValueError("Malformed x-signature header")
if tolerance > 0 and abs(int(time.time()) - t) > tolerance:
raise ValueError("Timestamp out of tolerance")
signed = f"{t}.{raw_body.decode('utf-8')}".encode()
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)function verifySignature(
string $rawBody,
string $signatureHeader,
string $secret,
int $tolerance = 300
): bool {
$parts = [];
foreach (explode(',', $signatureHeader) as $part) {
[$k, $v] = explode('=', $part, 2);
$parts[$k] = $v;
}
$t = (int) ($parts['t'] ?? 0);
$v1 = $parts['v1'] ?? '';
if (!$t || !$v1) {
throw new \RuntimeException('Malformed x-signature header');
}
if ($tolerance > 0 && abs(time() - $t) > $tolerance) {
throw new \RuntimeException('Timestamp out of tolerance');
}
$expected = hash_hmac('sha256', "{$t}.{$rawBody}", $secret);
return hash_equals($expected, $v1);
}package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
)
func VerifySignature(rawBody []byte, signatureHeader, secret string, tolerance int) error {
parts := make(map[string]string)
for _, p := range strings.Split(signatureHeader, ",") {
kv := strings.SplitN(p, "=", 2)
if len(kv) == 2 {
parts[kv[0]] = kv[1]
}
}
tStr, v1 := parts["t"], parts["v1"]
if tStr == "" || v1 == "" {
return errors.New("malformed x-signature header")
}
t, err := strconv.ParseInt(tStr, 10, 64)
if err != nil {
return errors.New("invalid timestamp in x-signature")
}
if tolerance > 0 {
diff := math.Abs(float64(time.Now().Unix() - t))
if int(diff) > tolerance {
return errors.New("timestamp out of tolerance")
}
}
signed := fmt.Sprintf("%d.%s", t, rawBody)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signed))
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(v1)) {
return errors.New("signature mismatch")
}
return nil
}Rotating secrets
To rotate a secret today, create a new webhook endpoint with the same URL and event selection, deploy code that recognises either secret, then delete the old endpoint. A dedicated "rotate-in-place" API is on the roadmap — this page will be updated when it ships.
Disabling the timestamp check
If you need to replay old events during development or testing, you can disable the timestamp check:
// ⚠️ Only for local testing — never disable in production
const event = billium.webhooks.verify(rawBody, signature, undefined, {
tolerance: 0,
});