Skip to content
Ledgerline Help

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

  1. 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.
  2. Split the header on commas, then on =, and extract t and every v1 value.
  3. Build the signed string: the timestamp, a literal period, then the raw body.
  4. Compute the HMAC-SHA256 of that string with your signing secret and hex-encode it.
  5. Compare your value to each v1 value using a constant-time comparison.
  6. Reject the request if t is 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.