Skip to content

Verifying signatures

Every delivery carries:

X-LuciBook-Signature: t=1758000000,v1=UldnU3lOu0JLQ4bYm8SGWJoQ3Fh3cD+3qBYQOh6t9jg=
  • t — unix timestamp the request was signed at.
  • v1 — HMAC-SHA256, base64-encoded, of the string "{t}.{body}" (the literal timestamp, a ., then the raw request body bytes — sign before any JSON re-serialisation on your end), keyed with your webhook secret.

The signature covers the body bytes only — never any other header, never the URL. Verify signatures on the exact bytes you received, not on a value you've re-encoded.

LuciBook serialises the payload with sorted keys and no extra whitespace (json.dumps(sort_keys=True, separators=(",", ":"))) before signing it — the signature is over that exact byte string. Any re-serialisation on your side breaks verification, even if the JSON is semantically identical: different key order, added whitespace, or a different number formatting all change the bytes. This is a common trap with frameworks that parse the body for you before your handler runs — for example Express's body-parser/express.json() middleware, or Django REST Framework's request.data — both hand you an already-parsed object, not the raw bytes. Capture the raw request body (e.g. Express's express.json({ verify: (req, res, buf) => { req.rawBody = buf } }), or Django's request.body before anything else touches it) and verify against that, before any JSON parsing happens.

During a secret rotation grace window the header can carry more than one v1= entry — one per active secret, e.g. t=1758000000,v1=<primary>,v1=<secondary>. Receivers MUST treat every v1= entry as a candidate and accept if any of them matches — do not assume the header has exactly one v1= pair, and do not keep only the last one you parse.

Reference verifiers

import base64
import hashlib
import hmac
import time

def verify(secret: str, signature_header: str, body: bytes, tolerance_seconds: int) -> bool:
    t = None
    v1_candidates = []
    for part in signature_header.split(","):
        key, _, value = part.partition("=")
        if key == "t":
            t = value
        elif key == "v1":
            v1_candidates.append(value)
    if t is None or not v1_candidates:
        return False
    if abs(time.time() - int(t)) > tolerance_seconds:
        return False
    expected = base64.b64encode(
        hmac.new(secret.encode(), f"{t}.{body.decode()}".encode(), hashlib.sha256).digest()
    ).decode()
    return any(hmac.compare_digest(expected, candidate) for candidate in v1_candidates)
const crypto = require("crypto");

function verify(secret, signatureHeader, rawBody, toleranceSeconds) {
  let t = null;
  const v1Candidates = [];
  for (const part of signatureHeader.split(",")) {
    const eq = part.indexOf("=");
    if (eq === -1) continue;
    const key = part.slice(0, eq);
    const value = part.slice(eq + 1);
    if (key === "t") t = value;
    else if (key === "v1") v1Candidates.push(value);
  }
  if (t === null || v1Candidates.length === 0) return false;
  if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest("base64");
  const expectedBuf = Buffer.from(expected);
  return v1Candidates.some((candidate) => {
    const candidateBuf = Buffer.from(candidate);
    if (candidateBuf.length !== expectedBuf.length) return false;
    try {
      return crypto.timingSafeEqual(expectedBuf, candidateBuf);
    } catch {
      return false;
    }
  });
}
package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/base64"
    "math"
    "strconv"
    "strings"
    "time"
)

func verify(secret, signatureHeader string, body []byte, toleranceSeconds int64) bool {
    var t string
    var v1Candidates []string
    for _, part := range strings.Split(signatureHeader, ",") {
        eq := strings.Index(part, "=")
        if eq == -1 {
            continue
        }
        key := part[:eq]
        value := part[eq+1:]
        if key == "t" {
            t = value
        } else if key == "v1" {
            v1Candidates = append(v1Candidates, value)
        }
    }
    if t == "" || len(v1Candidates) == 0 {
        return false
    }
    ts, err := strconv.ParseInt(t, 10, 64)
    if err != nil {
        return false
    }
    if math.Abs(float64(time.Now().Unix()-ts)) > float64(toleranceSeconds) {
        return false
    }
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(t + "." + string(body)))
    expected := []byte(base64.StdEncoding.EncodeToString(mac.Sum(nil)))
    for _, candidate := range v1Candidates {
        if hmac.Equal(expected, []byte(candidate)) {
            return true
        }
    }
    return false
}
require "openssl"
require "base64"

def verify(secret, signature_header, body, tolerance_seconds)
  t = nil
  v1_candidates = []
  signature_header.split(",").each do |part|
    key, sep, value = part.partition("=")
    next if sep.empty?
    if key == "t"
      t = value
    elsif key == "v1"
      v1_candidates << value
    end
  end
  return false if t.nil? || v1_candidates.empty?
  return false if (Time.now.to_i - t.to_i).abs > tolerance_seconds

  digest = OpenSSL::HMAC.digest("sha256", secret, "#{t}.#{body}")
  expected = Base64.strict_encode64(digest)

  # OpenSSL.fixed_length_secure_compare raises unless both strings are
  # the same length, so length-check first rather than pull in Rack
  # just for Rack::Utils.secure_compare.
  v1_candidates.any? do |candidate|
    candidate.bytesize == expected.bytesize &&
      OpenSSL.fixed_length_secure_compare(expected, candidate)
  end
end
<?php

function verify(string $secret, string $signatureHeader, string $body, int $toleranceSeconds): bool
{
    $t = null;
    $v1Candidates = [];
    foreach (explode(",", $signatureHeader) as $part) {
        $eq = strpos($part, "=");
        if ($eq === false) {
            continue;
        }
        $key = substr($part, 0, $eq);
        $value = substr($part, $eq + 1);
        if ($key === "t") {
            $t = $value;
        } elseif ($key === "v1") {
            $v1Candidates[] = $value;
        }
    }
    if ($t === null || count($v1Candidates) === 0) {
        return false;
    }
    if (abs(time() - (int) $t) > $toleranceSeconds) {
        return false;
    }
    $expected = base64_encode(hash_hmac("sha256", "{$t}.{$body}", $secret, true));
    foreach ($v1Candidates as $candidate) {
        if (hash_equals($expected, $candidate)) {
            return true;
        }
    }
    return false;
}

Test vector

Use this fixed vector to check your own implementation against LuciBook's — it is generated straight from the reference webhook_signing module, so it can never drift from what LuciBook actually sends. The secrets below are test-only values; never use them for a real subscription.

Field Value
Secret whsec_test_vector_primary
Timestamp (t) 1758000000
Raw body {"id":"evt_test_vector","type":"invoice.paid"}
Header t=1758000000,v1=ZNAOzRojbYj0qNn/1sbGygoIsLNWHGbcPItqWX7pzSY=

Verifying that header and body with the secret from the table must succeed. The verifiers above compare the timestamp against the current clock, so the vector is years outside the tolerance window: to test with it, temporarily treat "now" as 1758000000 in your copy (or skip the tolerance check for this one test).

Rotation example — the same timestamp and body, signed with both the primary secret above and a second, whsec_test_vector_secondary, produces two v1= entries:

Field Value
Secondary secret whsec_test_vector_secondary
Header t=1758000000,v1=ZNAOzRojbYj0qNn/1sbGygoIsLNWHGbcPItqWX7pzSY=,v1=UWjEXSwfI9TZn9uyl+PEesN0/ltW/F5ByqGObg/IKBw=

A verifier called with either secret alone must accept this header — each v1= value only matches its own secret, so trying the wrong secret against the wrong candidate still fails; the header is valid because some pairing matches.

Rotating your secret

When you rotate a subscription's secret (POST /api/v1/webhooks/subscriptions/<id>/rotate-secret), the old secret doesn't stop working immediately: it becomes the secondary secret, and both it and the new primary secret sign every delivery — you'll see two v1= entries in X-LuciBook-Signature during this window, as described above. This grace period lasts developer.secret_rotation_grace_days (currently 7 days — see policy defaults), giving you time to roll the new secret out to your verification code before the old one stops working.

To end the grace period early — for example if you suspect the old secret was compromised and want it dead immediately rather than in up to 7 days — pass revoke_previous_now: true on the rotate-secret call. This drops the secondary secret and its expiry right away: only the new primary verifies from that point on, and any deliveries in flight signed only with the old secret will fail verification. Note this must be the same call that performs the rotation — calling rotate-secret again afterwards mints yet another new primary rather than just clearing the secondary.

Once the grace window elapses (or you revoke it early), the secondary secret disappears — GETting the subscription's secret only ever returns it while secondary_secret_expires_at is still in the future.

Tolerance

Reject anything outside developer.webhook.signature_tolerance_seconds — see rate limits / policy defaults for the current value. This bounds how long a captured request stays replayable.

Idempotency

Retries and replays reuse the same event id — process each event id at most once (store the ids you've handled, or make the underlying write idempotent on it). Never assume "one event, one delivery attempt" — a retry after a timeout can arrive even if your first attempt actually succeeded.