Skip to content

Verify signatures

Every delivery is signed with the signing secret of your organization. LIGR recommends that you verify the signature before you trust the body. LIGR cannot enforce this: the check runs in your code, on your endpoint. The rules below describe the check LIGR recommends you write.

HeaderValue
content-typeapplication/json
x-ligr-webhook-timestampUnix time in milliseconds when LIGR signed the request
x-ligr-webhook-sigThe hex HMAC-SHA256 of v0:{timestamp}:{body}

LIGR joins three parts with colons.

v0:1757671471118:{"type":"update","entity":"fact",…}
  • v0 is the signature scheme.
  • The timestamp is the value of x-ligr-webhook-timestamp.
  • The body is the raw request body, byte for byte.
verify.ts
import { createHmac, timingSafeEqual } from 'node:crypto'
const FIVE_MINUTES = 5 * 60_000
export function verify(rawBody: string, headers: Record<string, string>, secret: string): boolean {
const ts = headers['x-ligr-webhook-timestamp']
const sig = headers['x-ligr-webhook-sig']
if (!ts || !sig) return false
if (Math.abs(Date.now() - Number(ts)) > FIVE_MINUTES) return false
const expected = createHmac('sha256', secret).update(`v0:${ts}:${rawBody}`).digest('hex')
if (sig.length !== expected.length) return false
return timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
}
verify.py
import hmac
import time
from hashlib import sha256
FIVE_MINUTES_MS = 5 * 60 * 1000
def verify(raw_body: bytes, headers: dict, secret: str) -> bool:
ts = headers.get("x-ligr-webhook-timestamp")
sig = headers.get("x-ligr-webhook-sig")
if not ts or not sig:
return False
if abs(time.time() * 1000 - int(ts)) > FIVE_MINUTES_MS:
return False
signed = f"v0:{ts}:".encode() + raw_body
expected = hmac.new(secret.encode(), signed, sha256).hexdigest()
return hmac.compare_digest(sig, expected)
  1. Compare in constant time. timingSafeEqual and compare_digest do this.
  2. Reject a request older than 5 minutes. This stops a replay.
  3. Reject a request with no signature header.
  4. Never log the secret.