Verifying webhook signatures
Updated 22 September 20263 min read
On this page
Anyone who knows the URL of your webhook endpoint can send it a request. Ledgerline signs every delivery so you can prove that a request came from us and that nobody changed it on the way. Verify the signature before you act on an event.
The signature header
Each delivery carries a Ledgerline-Signature header:
Ledgerline-Signature: t=1760000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
| Part | Meaning |
|---|---|
t |
Unix timestamp, in seconds, of when Ledgerline signed the payload. |
v1 |
Hex-encoded HMAC-SHA256 signature. Future schemes will appear as v2, v3 and so on. |
The signature is an HMAC-SHA256 of the string "{t}.{raw_body}", keyed with your endpoint's signing secret. The secret starts with whsec_ and is shown once when you create the endpoint. Each endpoint has its own secret.
How to verify
- Read the raw request body as bytes or an unparsed string. Do not parse and re-serialize the JSON, because any change to whitespace or key order breaks the signature.
- Split the header on commas, then on
=, and extracttand everyv1value. - Build the signed string: the timestamp, a literal period, then the raw body.
- Compute the HMAC-SHA256 of that string with your signing secret and hex-encode it.
- Compare your value to each
v1value using a constant-time comparison. - Reject the request if
tis more than 5 minutes away from your server clock. This blocks replay of old captured requests.
Warning
A plain == string comparison leaks timing information. Use crypto.timingSafeEqual in Node and hmac.compare_digest in Python.
Node.js without the SDK
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
export function verifyLedgerlineSignature(rawBody: string, header: string, secret: string): boolean {
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=", 2) as [string, string]),
);
const timestamp = Number(parts.t);
if (!Number.isFinite(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest();
const received = Buffer.from(parts.v1 ?? "", "hex");
return received.length === expected.length && timingSafeEqual(received, expected);
}
In Express, mount express.raw({ type: "application/json" }) on the webhook route so the body is not parsed before you verify it.
Python without the SDK
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def verify_ledgerline_signature(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(kv.split("=", 1) for kv in header.split(","))
timestamp = int(parts["t"])
if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
return False
signed = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
Use the SDK helper
The official SDKs implement all of the steps above, including the tolerance check.
const event = ll.webhooks.constructEvent(rawBody, req.headers["ledgerline-signature"], secret);
event = client.webhooks.construct_event(raw_body, request.headers["Ledgerline-Signature"], secret)
Both throw a SignatureVerificationError when the header is missing, malformed, outside the tolerance window or does not match.
Rotating the signing secret
Roll the secret from Developers > Webhooks in the dashboard, or with POST /v1/webhook_endpoints/{id}/roll_secret. For the next 24 hours Ledgerline sends two v1 values in the header, one per secret, so you can deploy the new secret without dropping events. Accept the request if either value matches.
Troubleshooting
| Symptom | Likely cause |
|---|---|
| Every signature fails | The body was parsed before verification, or a proxy altered it. Verify against the raw bytes. |
| Signatures fail for one endpoint only | You are using another endpoint's secret. Secrets are per endpoint. |
| Timestamp rejected | Your server clock has drifted by more than 5 minutes. Sync it with NTP. |
| Works locally, fails in production | A gateway adds a trailing newline or re-encodes the body. Capture the raw body at the edge. |
Deliveries that fail verification should return a 400 status. Ledgerline treats that as a failed delivery and retries on the delivery schedule. For event types and payloads, see the webhooks overview.