Lecturia Developers

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

To verify:

  1. Read the raw request body bytes — before any JSON parsing.
  2. Split the header on ,, take t= and v1=.
  3. Reject if |now - t| > 300 seconds (replay protection).
  4. Compute HMAC_SHA256(secret, t + "." + body) and hex-encode.
  5. Compare against v1 with 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

Full endpoint schemas: Webhooks in the API Reference.