Webhooks

Verifying Webhook Signatures

How the X-Booklink-Signature header works and how to check it in your own code.

Last updated

Your webhook URL is on the public internet, so anyone who guesses it could post fake bookings to it. Every request Booklink sends is signed with your endpoint’s secret, and checking that signature is how you prove the request really came from Booklink.

Always verify

Never act on a webhook payload before the signature check passes. Do not treat a hard-to-guess URL as security on its own.

The signature header

Every request carries a header in this form:

X-Booklink-Signature: t=1756458724,v1=3f9c2b7a1d8e...  
t
The Unix timestamp in seconds at the moment Booklink signed the request.
v1
The signature: a hex-encoded HMAC-SHA256 digest.

The signed string is the timestamp, a full stop, then the exact raw request body:

signed_payload = t + "." + raw_body
expected = hex( HMAC_SHA256(secret, signed_payload) )

Compare expected to the v1 value. If they match, the request is genuine.

Sign the raw body, not a re-encoded one

Use the exact bytes your framework received. If you parse the JSON and serialise it again before hashing, key order and whitespace change and the signature will never match. Most frameworks need an explicit raw-body option for this.

Node.js example

import crypto from "node:crypto";

function verify(rawBody, headerValue, secret) {
  const parts = Object.fromEntries(
    headerValue.split(",").map((p) => p.split("="))
  );

  const timestamp = Number(parts.t);
  if (!timestamp || !parts.v1) return false;

  // Reject anything older than five minutes.
  const age = Math.abs(Math.floor(Date.now() / 1000) - timestamp);
  if (age > 300) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(timestamp + "." + rawBody)
    .digest("hex");

  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1);
  if (a.length !== b.length) return false;

  return crypto.timingSafeEqual(a, b);
}

Python example

import hashlib
import hmac
import time


def verify(raw_body: bytes, header_value: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in header_value.split(","))

    timestamp = parts.get("t")
    signature = parts.get("v1")
    if not timestamp or not signature:
        return False

    # Reject anything older than five minutes.
    if abs(int(time.time()) - int(timestamp)) > 300:
        return False

    signed = timestamp.encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()

    return hmac.compare_digest(expected, signature)

Checking the timestamp

Both examples reject requests whose timestamp is more than five minutes old. This stops someone capturing a valid request and replaying it later.

Keep one thing in mind: the timestamp is generated per delivery attempt, not per event. A retry an hour later is signed fresh, so it passes the age check, and a manual redelivery you trigger from the Deliveries list is signed at that moment too. The age window protects against replay, not against a legitimately delayed event, which is why created in the payload is the field to use when you care about how old the underlying change is.

Rotating the secret

Regenerate secret on the endpoint issues a new secret and shows it once. The old one stops working immediately, including for retries of deliveries that were already queued, because Booklink reads the secret at the moment it sends each attempt.

To rotate without dropping events, deploy your server so it accepts either the old or the new secret, regenerate in Booklink, update your configuration, then remove the old secret. If a short gap is acceptable, you can simply regenerate, update, and redeliver anything that failed in between.

Store the secret as a secret

Keep it in environment variables or your secret manager, not in source control. If it leaks, regenerate immediately.

Was this article helpful?

Still need help?

Our support team is happy to help you get the most out of Booklink.

Contact support