Verifying webhook signatures
Every webhook delivery is signed. Verify the signature before trusting a payload — the HTTP endpoint you registered is reachable by anyone on the internet; the signature is what proves a POST came from Lecturia.
The signature format
Each delivery carries one header:
X-Lecturia-Signature: t=1754952463,v1=85bc499f4b604b0fbd424b6d59a2f5909e15ace1fd2ae18f83ceaf8d6835d364
t— unix timestamp (seconds) at signing time.v1— lower-case hex HMAC-SHA256 of the string<t>.<raw request body>, keyed with your endpoint's signing secret (whsec_..., shown exactly once at registration and on rotation).
To verify:
- Read the raw request body bytes — before any JSON parsing.
- Split the header on
,, taket=andv1=. - Reject if
|now - t| > 300seconds (replay protection). - Compute
HMAC_SHA256(secret, t + "." + body)and hex-encode. - Compare against
v1with a constant-time comparison.
Deliveries also send User-Agent: Lecturia-Webhooks/1.0 and
Content-Type: application/json — useful for filtering, not for trust.
TypeScript (Node)
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
export function verifyLecturiaSignature(
header: string,
rawBody: Buffer,
secret: string,
nowSeconds = Math.floor(Date.now() / 1000),
): boolean {
const parts = new Map(
header.split(",").map((pair) => pair.trim().split("=", 2) as [string, string]),
);
const timestamp = parts.get("t");
const signature = parts.get("v1");
if (!timestamp || !signature) return false;
const signedAt = Number(timestamp);
if (!Number.isFinite(signedAt)) return false;
if (Math.abs(nowSeconds - signedAt) > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(signature, "utf8");
return a.length === b.length && timingSafeEqual(a, b);
}
Express gotcha: express.json() consumes and re-serializes the body, so
JSON.stringify(req.body) will NOT byte-match what was signed. Capture the
raw bytes:
app.post(
"/webhooks/lecturia",
express.raw({ type: "application/json" }), // req.body is the raw Buffer
(req, res) => {
const ok = verifyLecturiaSignature(
req.header("X-Lecturia-Signature") ?? "",
req.body,
process.env.LECTURIA_WEBHOOK_SECRET!,
);
if (!ok) return res.status(400).send("bad signature");
const event = JSON.parse(req.body.toString("utf8")); // parse AFTER verifying
res.sendStatus(200);
},
);
Python
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def verify_lecturia_signature(
header: str,
raw_body: bytes,
secret: str,
now_seconds: int | None = None,
) -> bool:
parts = dict(
pair.strip().split("=", 1) for pair in header.split(",") if "=" in pair
)
timestamp, signature = parts.get("t"), parts.get("v1")
if not timestamp or not signature:
return False
now = now_seconds if now_seconds is not None else int(time.time())
try:
if abs(now - int(timestamp)) > TOLERANCE_SECONDS:
return False
except ValueError:
return False
expected = hmac.new(
secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
(FastAPI/Flask: use await request.body() / request.get_data() — the raw
bytes, not the parsed and re-serialized JSON.)
Go
package lecturia
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"math"
"strconv"
"strings"
"time"
)
const toleranceSeconds = 300
func VerifyLecturiaSignature(header string, rawBody []byte, secret string, now time.Time) bool {
var timestamp, signature string
for _, pair := range strings.Split(header, ",") {
key, value, ok := strings.Cut(strings.TrimSpace(pair), "=")
if !ok {
continue
}
switch key {
case "t":
timestamp = value
case "v1":
signature = value
}
}
if timestamp == "" || signature == "" {
return false
}
unix, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || math.Abs(float64(now.Unix()-unix)) > toleranceSeconds {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp))
mac.Write([]byte{'.'})
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}
Ruby
require "openssl"
TOLERANCE_SECONDS = 300
def verify_lecturia_signature(header, raw_body, secret, now = Time.now.to_i)
parts = header.split(",").filter_map { |pair| pair.strip.split("=", 2) }.to_h
timestamp = parts["t"]
signature = parts["v1"]
return false if timestamp.nil? || signature.nil?
return false if (now - timestamp.to_i).abs > TOLERANCE_SECONDS
expected = OpenSSL::HMAC.hexdigest(
"SHA256", secret, "#{timestamp}.#{raw_body}"
)
OpenSSL.secure_compare(expected, signature)
end
(Rails: request.raw_post is the raw body.)
Test yourself
Run your implementation against this fixed vector (generated by the same
server code that signs production deliveries). With the timestamp check
disabled — or now pinned to 1754952463 — it must verify:
secret: whsec_1111111111111111111111111111111111111111111111111111111111111111
timestamp: 1754952463
body: {"event":"material.ready","material_id":"01J9Z1AAAAAAAAAAAAAAAAAAAA"}
header: t=1754952463,v1=85bc499f4b604b0fbd424b6d59a2f5909e15ace1fd2ae18f83ceaf8d6835d364
Then flip one byte anywhere in the body — verification must fail.
Operational guidance
- Answer fast, work later. Return
2xxas soon as the signature checks out; do real processing async. A non-2xx(or slow) response is retried on a backoff of 1 s → 5 s → 30 s → 5 min → then hourly, with jitter, for up to 24 h from the first failure — then the delivery is dead-lettered (inspect and force-retry via the deliveries API). - Expect duplicates. Retries mean at-least-once delivery; make handling
idempotent (the delivery
event_idis your dedupe key). - Rotation:
POST /webhooks/{id}/rotate-secretreturns the new secret once. During rollout, verify against both old and new secrets and accept whichever matches. - Event types you can subscribe to:
material.ready,material.failed,chunk.completed,chunk.failed,tts_sync.completed,credits.low,credits.exhausted.
Full endpoint schemas: Webhooks in the API Reference.