Guides

Webhook Signatures

How to verify that webhook deliveries are authentic

Every webhook delivery from Nahook includes a cryptographic signature so you can verify it's authentic and hasn't been tampered with. Nahook follows the Standard Webhooks specification.

Signature Headers

Each delivery includes three headers:

HeaderDescriptionExample
webhook-idUnique message identifiermsg_abc123
webhook-timestampUnix timestamp (seconds)1711843200
webhook-signatureHMAC-SHA256 signaturev1,K7gNU3sdo+OL...

How Signing Works

The signature is computed as:

HMAC-SHA256(
  key: base64_decode(secret_without_prefix),
  message: "{webhook-id}.{webhook-timestamp}.{body}"
)

Where:

  • key is your endpoint's signing secret (whsec_...) with the whsec_ prefix stripped and then base64-decoded
  • message is the webhook-id, webhook-timestamp, and raw request body joined with .
  • The result is base64-encoded and prefixed with v1,

Verification in TypeScript

import { createHmac, timingSafeEqual } from "node:crypto";

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

  if (!msgId || !timestamp || !signature) {
    return false;
  }

  // Reject timestamps older than 5 minutes (replay protection)
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(timestamp, 10)) > 300) {
    return false;
  }

  // Strip "whsec_" prefix and decode the secret
  const secretBytes = Buffer.from(
    secret.replace(/^whsec_/, ""),
    "base64",
  );

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

  // Compare with provided signature (strip "v1," prefix)
  const provided = signature.split(",")[1];
  if (!provided) return false;

  return timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(provided),
  );
}

Usage in an Express/Fastify handler:

app.post("/webhooks", (req, res) => {
  const isValid = verifyWebhook(
    JSON.stringify(req.body), // raw body string
    req.headers as Record<string, string>,
    "whsec_YOUR_ENDPOINT_SECRET",
  );

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

  // Process the webhook
  console.log("Received event:", req.body);
  res.status(200).json({ received: true });
});

Always use the raw request body string for verification, not a parsed-then-re-serialized object. JSON re-serialization can change whitespace or key ordering, which breaks the signature.

Replay Protection

The webhook-timestamp header prevents replay attacks. Always verify that the timestamp is within an acceptable window (e.g., 5 minutes) before processing the event:

const now = Math.floor(Date.now() / 1000);
const timestamp = parseInt(headers["webhook-timestamp"], 10);

if (Math.abs(now - timestamp) > 300) {
  // Reject — too old or too far in the future
}

Idempotency

The webhook-id header uniquely identifies each delivery. Store processed IDs to prevent duplicate processing during retries:

const messageId = headers["webhook-id"];

if (await alreadyProcessed(messageId)) {
  return res.status(200).json({ received: true }); // Acknowledge but skip
}

await processEvent(req.body);
await markProcessed(messageId);

Signing Secret

Each endpoint has a unique signing secret in the format whsec_{base64_encoded_key}. Find it on the endpoint detail page in the dashboard. The secret is generated at endpoint creation and cannot be changed.