Skip to main content
Zavu signs all webhook requests with an HMAC-SHA256 signature. You should verify this signature to ensure the request is authentic and hasn’t been tampered with.

Signature Header

Every webhook request includes an X-Zavu-Signature header:
X-Zavu-Signature: t=1705312200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
The header contains:
PartDescription
tUnix timestamp (seconds) when the signature was generated
v1HMAC-SHA256 signature of the payload

Verifying Signatures

Step 1: Extract the Signature

Parse the X-Zavu-Signature header to get the timestamp and signature:
function parseSignature(header) {
  const parts = header.split(',');
  const timestamp = parts.find(p => p.startsWith('t=')).slice(2);
  const signature = parts.find(p => p.startsWith('v1=')).slice(3);
  return { timestamp: parseInt(timestamp), signature };
}

Step 2: Prepare the Signed Payload

Concatenate the timestamp and the raw request body:
const signedPayload = `${timestamp}.${rawBody}`;

Step 3: Compute the Expected Signature

Use HMAC-SHA256 with your webhook secret:
const crypto = require('crypto');

function computeSignature(secret, payload) {
  return crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');
}

Step 4: Compare Signatures

Use a constant-time comparison to prevent timing attacks:
const crypto = require('crypto');

function verifySignature(expected, actual) {
  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(actual)
  );
}

Complete Examples

import crypto from "crypto";
import express from "express";

const app = express();

// Use raw body for signature verification
app.use("/webhooks/zavu", express.raw({ type: "application/json" }));

function verifyZavuSignature(req, secret) {
  const signatureHeader = req.headers["x-zavu-signature"];
  if (!signatureHeader) {
    return false;
  }

  // Parse the signature header
  const parts = signatureHeader.split(",");
  const timestampPart = parts.find(p => p.startsWith("t="));
  const signaturePart = parts.find(p => p.startsWith("v1="));

  if (!timestampPart || !signaturePart) {
    return false;
  }

  const timestamp = parseInt(timestampPart.slice(2));
  const signature = signaturePart.slice(3);

  // Check timestamp (reject if older than 5 minutes)
  const now = Math.floor(Date.now() / 1000);
  if (now - timestamp > 300) {
    console.log("Webhook timestamp too old");
    return false;
  }

  // Compute expected signature
  const rawBody = req.body.toString();
  const signedPayload = `${timestamp}.${rawBody}`;
  const expectedSignature = crypto
    .createHmac("sha256", secret)
    .update(signedPayload)
    .digest("hex");

  // Constant-time comparison
  try {
    return crypto.timingSafeEqual(
      Buffer.from(expectedSignature),
      Buffer.from(signature)
    );
  } catch {
    return false;
  }
}

app.post("/webhooks/zavu", (req, res) => {
  const isValid = verifyZavuSignature(req, process.env.ZAVU_WEBHOOK_SECRET);

  if (!isValid) {
    console.log("Invalid webhook signature");
    return res.status(401).send("Invalid signature");
  }

  // Parse the body and process
  const event = JSON.parse(req.body.toString());
  console.log("Verified webhook:", event.type);

  res.status(200).send("OK");
});

app.listen(3000);

Timestamp Validation

Always validate the timestamp to prevent replay attacks:
const MAX_AGE_SECONDS = 300; // 5 minutes

function isTimestampValid(timestamp) {
  const now = Math.floor(Date.now() / 1000);
  return (now - timestamp) <= MAX_AGE_SECONDS;
}
Never skip signature verification in production. An attacker could send fake webhook events to your endpoint.

Troubleshooting

Signature Mismatch

If signature verification fails:
  1. Check your secret - Ensure you’re using the correct webhook secret from the sender’s webhook configuration
  2. Use raw body - The signature is computed on the raw request body, not parsed JSON
  3. Check encoding - Ensure the body is UTF-8 encoded
  4. Verify timestamp format - The timestamp in the signature is in seconds, not milliseconds

Testing Locally

For local development, you can temporarily disable signature verification or use a tool like ngrok to expose your local server.
// Development only - never use in production!
const SKIP_VERIFICATION = process.env.NODE_ENV === 'development';

app.post('/webhooks/zavu', (req, res) => {
  if (!SKIP_VERIFICATION && !verifyZavuSignature(req, secret)) {
    return res.status(401).send('Invalid signature');
  }
  // ...
});

Next Steps