Enquiries Channel manager Bookings Your website Booking engine Pricing
v2026-09

Verifying signatures

Every delivery is signed with your webhook secret. Verify it before you trust anything in the body, and reject what does not match.

The header

http
X-Doorloom-Signature: t=1759312504,v1=5f2a9c8d1e…
Field Type Description
t integer Unix timestamp, in seconds, of when the delivery was signed.
v1 string Lowercase hex HMAC-SHA256. There may be more than one — see secret rotation below.

How to verify

  1. Parse t and every v1 out of the header.
  2. Reject if t is more than 300 seconds from your clock.
  3. Compute HMAC-SHA256(secret, "{t}.{raw_body}"), hex-encoded.
  4. Accept if it matches any v1, compared in constant time.

Hash the raw request bytes

Sign the body exactly as it arrived — not a re-serialised copy of the parsed JSON. Key order, whitespace and unicode escaping all change the bytes and therefore the hash. Most frameworks make the parsed body easier to reach than the raw one; take the harder path. In Django, request.body; in Express you need express.raw() or verify.

Reference implementations

These are the implementations Doorloom tests against. Copying one is safer than writing your own.

Node.js javascript
const crypto = require('crypto');

function verify(secret, rawBody, header) {
  const parts = header.split(',').map((p) => p.trim());

  const t = Number((parts.find((p) => p.startsWith('t=')) || '').slice(2));
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${t}.${rawBody}`)
    .digest('hex');

  return parts
    .filter((p) => p.startsWith('v1='))
    .map((p) => p.slice(3))
    .some((sig) =>
      sig.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
    );
}
Python python
import hashlib
import hmac
import time


def verify(secret: str, raw_body: bytes, header: str) -> bool:
    timestamp = None
    signatures = []

    for part in header.split(","):
        key, _, value = part.strip().partition("=")
        if key == "t":
            timestamp = int(value)
        elif key == "v1":
            signatures.append(value.lower())

    if not timestamp or abs(time.time() - timestamp) > 300:
        return False

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

    return any(hmac.compare_digest(expected, sig) for sig in signatures)

Why more than one v1

When your webhook secret is rotated, the previous secret keeps verifying for 24 hours. Through that window Doorloom signs each delivery with both secrets and sends both results:

http
X-Doorloom-Signature: t=1759312504,v1=5f2a9c8d1e…,v1=b71e4a0c92…

This is what makes a rotation zero-downtime: whichever secret your running build holds, one of the values matches. An implementation that reads only the first v1= works perfectly until the day of a rotation and then rejects half its traffic — which is why both examples above loop.

What to return on a bad signature

Return 401. That is a non-retryable 4xx, so Doorloom dead-letters the delivery and alerts staff rather than hammering you — which is the outcome you want, because a signature failure means something is genuinely wrong with the secret rather than with the moment.

Do not skip the timestamp check

Without it, a captured payload stays replayable forever. With it, an attacker has a 5-minute window and still needs a valid signature. Reject anything outside ±300 seconds, and keep your server clock synchronised.

Something unclear or wrong on this page? Write to [email protected] and tell us which page — we would rather fix the doc than answer the ticket twice.