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=85bc499f4b604b0fbd424b6d59a2f5909e15ace1fd2ae18f83ceaf8d6835d364t— 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.
Samples below cover the canonical Lecturia example-language set — Go, TypeScript, Rust, Java, Ruby, Python. cURL is deliberately absent on this page: verification happens inside YOUR server receiving the POST, and there is no long-running curl server to put it in.
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))
}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);
},
);Rust
// Cargo.toml: hmac = "0.12", sha2 = "0.10", hex = "0.4"
use hmac::{Hmac, Mac};
use sha2::Sha256;
const TOLERANCE_SECONDS: i64 = 300;
pub fn verify_lecturia_signature(header: &str, raw_body: &[u8], secret: &str, now: i64) -> bool {
let mut timestamp = None;
let mut signature = None;
for pair in header.split(',') {
match pair.trim().split_once('=') {
Some(("t", value)) => timestamp = Some(value),
Some(("v1", value)) => signature = Some(value),
_ => {}
}
}
let (Some(timestamp), Some(signature)) = (timestamp, signature) else {
return false;
};
let Ok(signed_at) = timestamp.parse::<i64>() else {
return false;
};
if (now - signed_at).abs() > TOLERANCE_SECONDS {
return false;
}
let Ok(provided) = hex::decode(signature) else {
return false;
};
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("any key size works");
mac.update(timestamp.as_bytes());
mac.update(b".");
mac.update(raw_body);
// verify_slice is constant-time.
mac.verify_slice(&provided).is_ok()
}Java
// Java 17+, standard library only
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public final class LecturiaWebhooks {
private static final long TOLERANCE_SECONDS = 300;
public static boolean verify(String header, byte[] rawBody, String secret, long nowSeconds) {
String timestamp = null;
String signature = null;
for (String pair : header.split(",")) {
String[] parts = pair.trim().split("=", 2);
if (parts.length != 2) continue;
if (parts[0].equals("t")) timestamp = parts[1];
if (parts[0].equals("v1")) signature = parts[1];
}
if (timestamp == null || signature == null) return false;
long signedAt;
try {
signedAt = Long.parseLong(timestamp);
} catch (NumberFormatException e) {
return false;
}
if (Math.abs(nowSeconds - signedAt) > TOLERANCE_SECONDS) return false;
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update(timestamp.getBytes(StandardCharsets.UTF_8));
mac.update((byte) '.');
byte[] expected = mac.doFinal(rawBody);
// MessageDigest.isEqual is constant-time; parseHex throws on
// non-hex input, which lands in the catch -> false.
return MessageDigest.isEqual(expected, HexFormat.of().parseHex(signature));
} catch (Exception e) {
return false;
}
}
}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.)
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.)
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=85bc499f4b604b0fbd424b6d59a2f5909e15ace1fd2ae18f83ceaf8d6835d364Then 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.