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.
Headers
Section titled “Headers”| Header | Value |
|---|---|
content-type | application/json |
x-ligr-webhook-timestamp | Unix time in milliseconds when LIGR signed the request |
x-ligr-webhook-sig | The hex HMAC-SHA256 of v0:{timestamp}:{body} |
The signed string
Section titled “The signed string”LIGR joins three parts with colons.
v0:1757671471118:{"type":"update","entity":"fact",…}v0is the signature scheme.- The timestamp is the value of
x-ligr-webhook-timestamp. - The body is the raw request body, byte for byte.
Verify in Node
Section titled “Verify in Node”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 in Python
Section titled “Verify in Python”import hmacimport timefrom 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)- Compare in constant time.
timingSafeEqualandcompare_digestdo this. - Reject a request older than 5 minutes. This stops a replay.
- Reject a request with no signature header.
- Never log the secret.