Skip to main content

Why Verify Webhooks?

Security is critical. Anyone can send a POST request to your webhook endpoint. Without verification, malicious actors could:
  • Send fake events to corrupt your data
  • Trigger unwanted actions in your application
  • Cause your system to process fraudulent information
  • Launch denial-of-service attacks
Always verify webhook signatures to ensure requests are genuinely from AutoSend.

How AutoSend Signs Webhooks

Every webhook request from AutoSend includes an X-Webhook-Signature header containing an HMAC-SHA256 signature.

Signature Generation

AutoSend generates the signature using this process:
  1. Take the raw request body as a string (the JSON payload)
  2. Compute HMAC-SHA256 using your webhook secret as the key
  3. Convert to hexadecimal format
  4. Add as header: X-Webhook-Signature: <signature>
// Pseudocode for how AutoSend generates signatures
const signature = crypto
  .createHmac("sha256", webhookSecret)
  .update(JSON.stringify(requestBody))
  .digest("hex");

Webhook Request Headers

Every webhook request includes these headers:
X-Webhook-Signature
string
required
HMAC-SHA256 signature of the request bodyExample: "a1b2c3d4e5f6..."
X-Webhook-Event
string
required
The event typeExample: "email.opened"
X-Webhook-Delivery-Id
string
required
Unique delivery identifierExample: "delivery-123..."
X-Webhook-Timestamp
string
required
Unix timestamp when webhook was sentExample: "1699790400"
Content-Type
string
required
Always application/jsonExample: "application/json"
User-Agent
string
required
AutoSend user agentExample: "AutoSend-Webhooks/1.0"

Steps to verify Signatures

1

Extract the signature from the X-Webhook-Signature header

2

Get the raw request body as a string (before parsing)

3

Compute the expected signature using your webhook secret

4

Compare signatures using a constant-time comparison function

const express = require("express");
const crypto = require("crypto");

const app = express();

// Important: Store raw body for signature verification
app.use(
  express.json({
    verify: (req, res, buf) => {
      req.rawBody = buf.toString("utf8");
    },
  })
);

function verifyWebhookSignature(req, webhookSecret) {
  const receivedSignature = req.headers["x-webhook-signature"];

  if (!receivedSignature) {
    return false;
  }

  // Compute expected signature using raw body
  const expectedSignature = crypto
    .createHmac("sha256", webhookSecret)
    .update(req.rawBody)
    .digest("hex");

  // Use constant-time comparison to prevent timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(receivedSignature),
    Buffer.from(expectedSignature)
  );
}

app.post("/webhooks/autosend", (req, res) => {
  const webhookSecret = process.env.WEBHOOK_SECRET;

  // Verify signature
  if (!verifyWebhookSignature(req, webhookSecret)) {
    console.error("Invalid webhook signature");
    return res.status(401).json({ error: "Invalid signature" });
  }

  // Process webhook
  const { event, data } = req.body;
  console.log(`Verified webhook: ${event}`);

  res.status(200).json({ received: true });
});

Retrieving Your Webhook Secret

If you’ve lost your webhook secret, you can retrieve it:
1

Navigate to Webhooks from the AutoSend sidebar

2

Click on the webhook

3

Click Reveal Secret

4

Copy the secret and store it securely

Complete Example

Here’s a complete, production-ready webhook endpoint with signature verification:
const express = require("express");
const crypto = require("crypto");
const app = express();

// Store raw body for signature verification
app.use(
  express.json({
    verify: (req, res, buf) => {
      req.rawBody = buf.toString("utf8");
    },
  })
);

// Webhook secret from environment
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;

if (!WEBHOOK_SECRET) {
  throw new Error("WEBHOOK_SECRET environment variable is required");
}

// Verify webhook signature
function verifyWebhookSignature(req) {
  const receivedSignature = req.headers["x-webhook-signature"];

  if (!receivedSignature) {
    return false;
  }

  const expectedSignature = crypto
    .createHmac("sha256", WEBHOOK_SECRET)
    .update(req.rawBody)
    .digest("hex");

  return crypto.timingSafeEqual(
    Buffer.from(receivedSignature),
    Buffer.from(expectedSignature)
  );
}

// Validate timestamp (optional but recommended)
function isTimestampValid(timestamp, maxAgeSeconds = 300) {
  const now = Math.floor(Date.now() / 1000);
  const age = now - parseInt(timestamp);
  return age < maxAgeSeconds && age > -60;
}

// Webhook endpoint
app.post("/webhooks/autosend", async (req, res) => {
  const deliveryId = req.headers["x-webhook-delivery-id"];
  const timestamp = req.headers["x-webhook-timestamp"];

  // Validate timestamp
  if (!isTimestampValid(timestamp)) {
    console.error("Invalid timestamp", { deliveryId, timestamp });
    return res.status(401).json({ error: "Invalid timestamp" });
  }

  // Verify signature
  if (!verifyWebhookSignature(req)) {
    console.error("Invalid signature", { deliveryId });
    return res.status(401).json({ error: "Invalid signature" });
  }

  // Process webhook
  const { event, data } = req.body;

  console.log("Webhook received and verified", {
    deliveryId,
    event,
    timestamp: new Date(parseInt(timestamp) * 1000).toISOString(),
  });

  try {
    // Queue for background processing
    await processWebhookAsync(event, data);

    res.status(200).json({ received: true });
  } catch (error) {
    console.error("Error processing webhook", { deliveryId, error });
    res.status(500).json({ error: "Internal error" });
  }
});

app.listen(3000, () => {
  console.log("Webhook server listening on port 3000");
});

Security Best Practices

Never use === or == to compare signatures. Use constant-time comparison functions to prevent timing attacks:
// ❌ Bad - Vulnerable to timing attacks
if (receivedSignature === expectedSignature) {
  // Process webhook
}

// ✅ Good - Constant-time comparison
if (
  crypto.timingSafeEqual(
    Buffer.from(receivedSignature),
    Buffer.from(expectedSignature)
  )
) {
  // Process webhook
}
Never hardcode webhook secrets in your code:
// ❌ Bad
const WEBHOOK_SECRET = "a1b2c3d4e5f6g7h8...";

// ✅ Good
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;

if (!WEBHOOK_SECRET) {
  throw new Error("WEBHOOK_SECRET environment variable is required");
}
Store secrets in:
  • Environment variables
  • Secure secret management services (AWS Secrets Manager, HashiCorp Vault, etc.)
  • Encrypted configuration files
Never:
  • Commit secrets to version control
  • Include secrets in client-side code
  • Share secrets in logs or error messages
Compute signatures using the raw, unparsed request body:
// ✅ Good - Use raw body
app.use(
  express.json({
    verify: (req, res, buf) => {
      req.rawBody = buf.toString("utf8");
    },
  })
);

const signature = crypto
  .createHmac("sha256", secret)
  .update(req.rawBody) // Use raw body
  .digest("hex");

// ❌ Bad - Don't re-stringify parsed body
const signature = crypto
  .createHmac("sha256", secret)
  .update(JSON.stringify(req.body)) // May not match original
  .digest("hex");
Optionally, validate the X-Webhook-Timestamp header to reject old requests:
function isWebhookTimestampValid(timestamp, maxAgeSeconds = 300) {
  const now = Math.floor(Date.now() / 1000);
  const age = now - parseInt(timestamp);

  // Reject if older than 5 minutes
  return age < maxAgeSeconds && age > -60; // Allow 1 minute clock skew
}

app.post("/webhooks/autosend", (req, res) => {
  const timestamp = req.headers["x-webhook-timestamp"];

  if (!isWebhookTimestampValid(timestamp)) {
    return res.status(401).json({ error: "Timestamp too old" });
  }

  if (!verifyWebhookSignature(req, webhookSecret)) {
    return res.status(401).json({ error: "Invalid signature" });
  }

  // Process webhook
});
Always use HTTPS for your webhook endpoints in production:
// ❌ Bad - HTTP in production
const webhookUrl = "http://api.example.com/webhooks/autosend";

// ✅ Good - HTTPS
const webhookUrl = "https://api.example.com/webhooks/autosend";
HTTPS ensures:
  • Requests are encrypted in transit
  • Man-in-the-middle attacks are prevented
  • Webhook data remains confidential
Regularly rotate your webhook secrets:
  1. Create a new webhook with a new secret
  2. Update your application to accept both old and new secrets temporarily
  3. Switch traffic to the new webhook
  4. Remove the old webhook
// Support multiple secrets during rotation
const WEBHOOK_SECRETS = [
  process.env.WEBHOOK_SECRET,
  process.env.WEBHOOK_SECRET_OLD, // Remove after rotation complete
].filter(Boolean);

function verifyWithMultipleSecrets(req) {
  return WEBHOOK_SECRETS.some((secret) => verifyWebhookSignature(req, secret));
}

Troubleshooting

Symptoms: All webhook requests return 401 UnauthorizedCommon Causes:
  1. Using wrong secret
    NodeJs
    // Check which secret you're using
    console.log("Using secret starting with:", webhookSecret.substring(0, 10));
    
  2. Body parsing issues
    NodeJs
    // Ensure you're using raw body
    console.log("Raw body:", req.rawBody);
    console.log("Parsed body:", JSON.stringify(req.body));
    
  3. String encoding issues
    NodeJs
    // Ensure consistent encoding
    const expectedSignature = crypto
      .createHmac("sha256", webhookSecret)
      .update(req.rawBody, "utf8") // Explicit encoding
      .digest("hex");
    
  4. Comparing wrong values
    NodeJs
    // Debug signature comparison
    console.log("Received signature:", receivedSignature);
    console.log("Expected signature:", expectedSignature);
    console.log("Match:", receivedSignature === expectedSignature);
    
Test your signature verification locally:
NodeJs
const crypto = require("crypto");

function testSignatureVerification() {
  const webhookSecret = "test-secret-12345";
  const payload = JSON.stringify({
    event: "email.opened",
    timestamp: "2025-11-12T10:30:00.000Z",
    data: { emailId: "test-123" },
  });

  // Generate signature
  const signature = crypto
    .createHmac("sha256", webhookSecret)
    .update(payload)
    .digest("hex");

  console.log("Test payload:", payload);
  console.log("Test signature:", signature);

  // Verify it works
  const expectedSignature = crypto
    .createHmac("sha256", webhookSecret)
    .update(payload)
    .digest("hex");

  console.log("Verification:", signature === expectedSignature);
}

testSignatureVerification();