Skip to main content
Every webhook delivery is signed with the endpoint’s signing secret using HMAC-SHA256. You must verify the signature before processing any event to prevent spoofed requests.

Signature scheme

The signature is computed over:
{webhook_id}.{timestamp}.{body}
Where:
  • webhook_id is the Webhook-Id header
  • timestamp is the Webhook-Timestamp header (Unix seconds)
  • body is the raw request body (not parsed JSON)

Verification steps

1

Extract headers

Read Webhook-Id, Webhook-Timestamp, and Webhook-Signature from the request headers.
2

Check timestamp

Reject if Webhook-Timestamp is more than 5 minutes old to prevent replay attacks.
3

Compute expected signature

Build the signed content string and HMAC-SHA256 it with your secret (base64-decoded, without the whsec_ prefix).
4

Compare signatures

Use constant-time comparison to match the expected signature against the Webhook-Signature header. The header may contain multiple signatures separated by spaces — match against any one.

Examples

import crypto from "crypto";

function verifyWebhook(
  payload: string,
  headers: Record<string, string>,
  secret: string, // "whsec_..."
): boolean {
  const msgId = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = headers["webhook-signature"];

  if (!msgId || !timestamp || !signatures) return false;

  // Reject stale timestamps (5-minute window)
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(timestamp)) > 300) return false;

  // Decode the secret (strip whsec_ prefix, base64-decode)
  const secretBytes = Buffer.from(secret.replace("whsec_", ""), "base64");

  // Compute expected signature
  const signedContent = `${msgId}.${timestamp}.${payload}`;
  const expected = crypto
    .createHmac("sha256", secretBytes)
    .update(signedContent)
    .digest("base64");

  // Compare against any signature in the header
  const expectedSig = `v1,${expected}`;
  return signatures
    .split(" ")
    .some(
      (sig) =>
        sig.length === expectedSig.length &&
        crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig)),
    );
}

Express.js middleware example

import express from "express";

app.post("/webhooks/agg", express.raw({ type: "application/json" }), (req, res) => {
  const isValid = verifyWebhook(
    req.body.toString(),
    req.headers as Record<string, string>,
    process.env.AGG_WEBHOOK_SECRET!,
  );

  if (!isValid) {
    return res.status(401).json({ error: "Invalid signature" });
  }

  const event = JSON.parse(req.body.toString());
  console.log(`Received ${event.type}:`, event.data);

  // Process the event...

  res.status(200).json({ received: true });
});
Always use express.raw() or equivalent to get the raw body. Parsing the body as JSON first and re-stringifying can change whitespace and break signature verification.