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.

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=85bc499f4b604b0fbd424b6d59a2f5909e15ace1fd2ae18f83ceaf8d6835d364

Then flip one byte anywhere in the body — verification must fail.

Operational guidance

Full endpoint schemas: Webhooks in the API Reference.