Billium
Webhooks

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...
PartDescription
tUnix timestamp in seconds of when the delivery was dispatched
v1HMAC-SHA256 hex digest of "{t}.{raw_body}" using your webhook secret

How verification works

  1. Parse the x-signature header to extract t and v1.
  2. Check that t is within an acceptable window of the current time (default ±300 s) to protect against replay attacks.
  3. Recompute the HMAC: HMAC-SHA256(secret, "{t}.{raw_body}").
  4. Compare the result to v1 using 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.

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,
});

On this page