If you set a secret on a LayBuild webhook, every delivery carries an HMAC-SHA256 signature computed over the string {timestamp}.{raw body}, where the timestamp is the ISO 8601 value in the X-Webhook-Timestamp header. To verify it: read the body as raw text, recompute the HMAC with your secret, compare in constant time, and reject timestamps that are too old.

The rest of this post shows exactly what a delivery looks like, a complete receiver for Bun (the same crypto code runs on Node), how to rotate the secret without dropping events, and what the signature does not protect you from.

What a delivery looks like

Each delivery is a POST with a JSON body. These are the headers LayBuild sends; values in angle brackets vary per delivery.

text
POST /your/path HTTP/1.1
Content-Type: application/json
User-Agent: ai-agent-engine-webhooks/1.0
X-Webhook-Event: <CONVERSATION_CREATED | MESSAGE_RECEIVED | CONVERSATION_HANDOFF | CONVERSATION_CLOSED>
X-Webhook-Delivery: <UUID>
X-Webhook-Timestamp: <ISO 8601 UTC, e.g. 2026-09-25T10:15:30.123Z>
X-Aichat-Event: <same as X-Webhook-Event>
X-Aichat-Delivery: <same as X-Webhook-Delivery>
X-Aichat-Timestamp: <same as X-Webhook-Timestamp>
X-Hub-Signature-256: sha256=<64 lowercase hex characters>
X-Aichat-Signature: sha256=<same value>

The X-Aichat-* headers duplicate the X-Webhook-* ones and the two signature headers carry the same value; use whichever pair your tooling expects. The signature headers are present only when the webhook has a secret. If you configured an authorization option on the webhook (bearer token, basic auth or an API key header), that header is added too.

The body is an envelope around the event data:

json
{
  "event": "MESSAGE_RECEIVED",
  "id": "<same UUID as X-Webhook-Delivery>",
  "timestamp": "<same value as X-Webhook-Timestamp>",
  "data": {
    "conversationId": "<UUID>",
    "messageId": "<UUID>",
    "role": "USER",
    "content": "Where can I download my invoice?",
    "createdAt": "<ISO 8601>"
  }
}

How the signature is computed

On LayBuild's side, the dispatcher serialises the envelope once with JSON.stringify, sends that exact string as the body, and signs it:

text
signed string = X-Webhook-Timestamp + "." + raw request body
signature     = hex( HMAC-SHA256( key = your webhook secret, message = signed string ) )
header value  = "sha256=" + signature

Including the timestamp in the signed string means an attacker who captures a delivery cannot change its timestamp without invalidating the signature. That is what makes a freshness check meaningful.

A complete receiver in Bun

This receiver verifies the signature, rejects stale timestamps, ignores repeated delivery IDs and acknowledges quickly. It uses node:crypto, which Bun supports, so the verification functions run unchanged on Node.

typescript
import { createHmac, timingSafeEqual } from 'node:crypto';

type LayBuildEvent =
  | 'CONVERSATION_CREATED'
  | 'MESSAGE_RECEIVED'
  | 'CONVERSATION_HANDOFF'
  | 'CONVERSATION_CLOSED';

interface LayBuildEnvelope {
  event: LayBuildEvent;
  id: string;
  timestamp: string;
  data: Record<string, unknown>;
}

// Two secrets so you can rotate without dropping deliveries (see below).
const SECRETS = [
  process.env.LAYBUILD_WEBHOOK_SECRET,
  process.env.LAYBUILD_WEBHOOK_SECRET_PREVIOUS,
].filter((s): s is string => typeof s === 'string' && s.length > 0);

const MAX_AGE_MS = 5 * 60 * 1000;
const seenDeliveries = new Map<string, number>();

function signatureMatches(header: string | null, timestamp: string, rawBody: string): boolean {
  if (!header || !header.startsWith('sha256=')) return false;
  const received = Buffer.from(header.slice('sha256='.length), 'hex');
  return SECRETS.some((secret) => {
    const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest();
    // timingSafeEqual throws on unequal lengths, and malformed hex decodes to fewer bytes.
    return received.length === expected.length && timingSafeEqual(received, expected);
  });
}

function isFresh(timestamp: string, now: number): boolean {
  const sentAt = Date.parse(timestamp);
  return !Number.isNaN(sentAt) && Math.abs(now - sentAt) <= MAX_AGE_MS;
}

function isFirstDelivery(deliveryId: string, now: number): boolean {
  // In-memory is fine for one process. With several instances, use Redis SET NX with a TTL.
  for (const [id, seenAt] of seenDeliveries) {
    if (now - seenAt > MAX_AGE_MS) seenDeliveries.delete(id);
  }
  if (seenDeliveries.has(deliveryId)) return false;
  seenDeliveries.set(deliveryId, now);
  return true;
}

async function enqueue(envelope: LayBuildEnvelope): Promise<void> {
  // Replace with a durable write (a table or a queue). Keep it fast.
  console.log(`queued ${envelope.event} ${envelope.id}`);
}

if (SECRETS.length === 0) {
  throw new Error('LAYBUILD_WEBHOOK_SECRET is not set');
}

Bun.serve({
  port: 8787,
  async fetch(req) {
    const url = new URL(req.url);
    if (req.method !== 'POST' || url.pathname !== '/webhooks/laybuild') {
      return new Response('Not found', { status: 404 });
    }

    // Verify the exact text received. Parsing and re-serialising can change key order or spacing.
    const rawBody = await req.text();
    const timestamp = req.headers.get('x-webhook-timestamp') ?? '';
    const deliveryId = req.headers.get('x-webhook-delivery') ?? '';
    const now = Date.now();

    if (!isFresh(timestamp, now)) {
      return new Response('Missing or stale timestamp', { status: 400 });
    }
    if (!signatureMatches(req.headers.get('x-hub-signature-256'), timestamp, rawBody)) {
      return new Response('Invalid signature', { status: 401 });
    }
    if (!isFirstDelivery(deliveryId, now)) {
      return new Response(null, { status: 204 });
    }

    let envelope: LayBuildEnvelope;
    try {
      envelope = JSON.parse(rawBody) as LayBuildEnvelope;
    } catch {
      return new Response('Malformed JSON', { status: 400 });
    }

    await enqueue(envelope);
    return new Response(null, { status: 204 });
  },
});

The order of checks matters a little. Checking freshness first is cheap and rejects old captures before any HMAC work. Deduplication comes after signature verification, so an unauthenticated request cannot fill your seen-IDs store.

On Node, Express and other frameworks

The verification functions work as-is on Node 18 and later. What changes is how you get the raw body. With the plain node:http server, collect the chunks and decode them yourself:

typescript
import { createServer } from 'node:http';

createServer((req, res) => {
  const chunks: Buffer[] = [];
  req.on('data', (chunk: Buffer) => chunks.push(chunk));
  req.on('end', () => {
    const rawBody = Buffer.concat(chunks).toString('utf8');
    // Node lowercases incoming header names.
    const timestamp = String(req.headers['x-webhook-timestamp'] ?? '');
    const signature = req.headers['x-hub-signature-256'];
    // ...same isFresh / signatureMatches / isFirstDelivery checks as above...
    res.writeHead(204).end();
  });
}).listen(8787);

In Express, a global express.json() parses the body before your handler sees it, and the raw text is gone. Mount express.raw({ type: 'application/json' }) on the webhook route and call req.body.toString('utf8'). Most frameworks have an equivalent "raw body" option; use it for this one route.

Rotating the secret without dropping events

A webhook has one secret at a time. The moment you change it in LayBuild, deliveries are signed with the new value, and there are no retries to catch anything your receiver rejects in the meantime. So rotate in this order:

  • Add the new secret to your receiver as the primary and keep the old one as LAYBUILD_WEBHOOK_SECRET_PREVIOUS. Deploy.
  • Change the secret on the webhook in LayBuild.
  • Once you see deliveries verifying against the new secret, remove the old one from the receiver.

The receiver above already accepts either secret, which is what makes this safe.

Mistakes that break verification

These come up often enough to list.

Verifying a re-serialised body. JSON.stringify(JSON.parse(body)) is not guaranteed to reproduce the bytes LayBuild signed. Always sign-check the raw text.

Leaving out the timestamp or the dot. The signed string is the timestamp, a literal ., then the body. HMAC over the body alone will never match.

Comparing with ===. String comparison can return early on the first differing character, which leaks timing information. Compare the decoded bytes with timingSafeEqual.

Forgetting the sha256= prefix, or comparing a hex string against a base64 digest. LayBuild sends lowercase hex after the prefix.

Accepting unsigned requests. If the signature header is missing, reject the request. A receiver that verifies "when a signature is present" can be bypassed by removing it.

What the signature does not cover

A valid signature tells you the request came from someone holding your secret and was not modified. It says nothing about delivery. LayBuild makes one attempt per event with a 5-second timeout, logs the result, and does not retry; there is no dead-letter queue. Payloads over 256 KB are not sent at all. Events can arrive out of order, and the same conversation can produce more than one handoff or close event. The event-driven workflows post covers how to build a receiver that tolerates all of that.

The signature also doesn't make message content safe to render. content in MESSAGE_RECEIVED is whatever the customer typed. Escape it before putting it in HTML, a chat card or an email.

Next steps