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.