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. Format the webhook payload with the event type, timestamp, and event data
  2. Convert the payload to JSON string (the raw request body)
  3. Compute HMAC-SHA256 using your webhook secret as the key
  4. Convert to hexadecimal format
  5. Add as header: X-Webhook-Signature: <signature>
// How AutoSend generates signatures
const payload = {
  type: event,
  createdAt: new Date().toISOString(),
  data: eventData,
};

const payloadString = JSON.stringify(payload);
const signature = crypto
  .createHmac('sha256', webhookSecret)
  .update(payloadString)
  .digest('hex');

Webhook Request Headers

Every webhook request includes these headers:
X-Webhook-Signature
string
required
HMAC-SHA256 signature of the request body in hexadecimal formatExample: "a1b2c3d4e5f6..."
X-Webhook-Event
string
required
The event typeExample: "email.opened"
X-Webhook-Delivery-Id
string
required
Unique delivery identifier (job ID from the queue system)Example: "delivery-123..."
X-Webhook-Timestamp
string
required
Unix timestamp in milliseconds when the webhook was sentExample: "1699790400000"
Content-Type
string
required
Always application/jsonExample: "application/json"
User-Agent
string
AutoSend user agent (if set)Example: "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
try {
return crypto.timingSafeEqual(
Buffer.from(receivedSignature),
Buffer.from(expectedSignature)
);
} catch (error) {
// Buffer lengths don't match
return false;
}
}

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 { type, data } = req.body;
console.log(`Verified webhook: ${type}`);

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


Retrieving Your Webhook Secret

Your webhook secret is shown only once when you create the webhook. If you’ve lost it, you can retrieve it:
1

Navigate to Webhooks from the AutoSend sidebar

2

Click on the webhook you want to manage

3

Click the Reveal Secret button

4

Copy the secret and store it securely in your environment variables

Store your webhook secret securely. Never commit it to version control or expose it in client-side code.

Complete Production Example

Here’s a complete, production-ready webhook endpoint with signature verification, timestamp validation, and error handling:
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");

try {
return crypto.timingSafeEqual(
Buffer.from(receivedSignature),
Buffer.from(expectedSignature)
);
} catch (error) {
// Buffer lengths don't match
return false;
}
}

// Validate timestamp (optional but recommended)
function isTimestampValid(timestamp, maxAgeSeconds = 300) {
if (!timestamp) {
return false;
}

const now = Date.now();
const age = now - parseInt(timestamp);

// Reject if older than 5 minutes or more than 1 minute in the future
return age < maxAgeSeconds \* 1000 && age > -60000;
}

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

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

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

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

console.log("Webhook received and verified", {
deliveryId,
type,
event,
createdAt,
});

try {
// Queue for background processing to respond quickly
await processWebhookAsync(type, data);

    // Respond with 200 to acknowledge receipt
    res.status(200).json({ received: true });

} catch (error) {
console.error("Error processing webhook", { deliveryId, error });
// Still return 200 to avoid retries for processing errors
// Log the error for investigation
res.status(200).json({ received: true, warning: "Processing queued" });
}
});

async function processWebhookAsync(type, data) {
// Implement your webhook processing logic here
// This should be non-blocking and ideally queued
console.log(`Processing ${type} event:`, data);
}

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
try {
  if (
    crypto.timingSafeEqual(
      Buffer.from(receivedSignature),
      Buffer.from(expectedSignature)
    )
  ) {
    // Process webhook
  }
} catch (error) {
  // Buffer lengths don't match - signature is invalid
  return false;
}
The crypto.timingSafeEqual() function throws an error if the buffer lengths don’t match. Always wrap it in a try-catch block.
Never hardcode webhook secrets in your code:
// ❌ Bad - Hardcoded secret
const WEBHOOK_SECRET = 'a1b2c3d4e5f6g7h8...';

// ✅ Good - Environment variable
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;

if (!WEBHOOK_SECRET) {
  throw new Error('WEBHOOK_SECRET environment variable is required');
}
Store secrets in:
  • Environment variables (.env files for local development)
  • 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
  • Use the same secret across multiple environments
Compute signatures using the raw, unparsed request body. Do not re-stringify the parsed JSON:
// ✅ 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');
JSON stringification is not deterministic. The order of object keys may differ, causing signature verification to fail.
Validate the X-Webhook-Timestamp header to reject old or replayed requests:
function isTimestampValid(timestamp, maxAgeSeconds = 300) {
  if (!timestamp) {
    return false;
  }

  const now = Date.now();
  const age = now - parseInt(timestamp);

  // Reject if older than 5 minutes or more than 1 minute in the future
  return age < maxAgeSeconds * 1000 && age > -60000;
}

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

  if (!isTimestampValid(timestamp)) {
    return res.status(401).json({ error: 'Invalid or expired timestamp' });
  }

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

  // Process webhook
});
The timestamp is in milliseconds (not seconds). AutoSend sends timestamps as Date.now().toString().
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
  • Your webhook secret is protected
AutoSend does not enforce HTTPS for webhook URLs, but it is strongly recommended for production use.
Webhook requests have a 10-second timeout. Always respond within this time:
// ✅ Good - Queue processing and respond immediately
app.post('/webhooks/autosend', async (req, res) => {
  // Verify signature
  if (!verifyWebhookSignature(req, webhookSecret)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  // Queue for background processing
  await queue.add('process-webhook', req.body);

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

// ❌ Bad - Long processing blocks response
app.post('/webhooks/autosend', async (req, res) => {
  // This might take too long
  await processWebhook(req.body);
  await updateDatabase(req.body);
  await sendNotification(req.body);

  res.status(200).json({ received: true });
});
If your endpoint doesn’t respond within 10 seconds, AutoSend will consider the delivery failed and retry up to 3 times.
AutoSend retries failed deliveries up to 3 times. Make your webhook handler idempotent:
// ✅ Good - Idempotent processing
app.post('/webhooks/autosend', async (req, res) => {
  const deliveryId = req.headers['x-webhook-delivery-id'];

  // Check if already processed
  const exists = await db.webhookLogs.findOne({ deliveryId });
  if (exists) {
    return res.status(200).json({ received: true, status: 'duplicate' });
  }

  // Process webhook
  await processWebhook(req.body);

  // Store delivery ID
  await db.webhookLogs.create({ deliveryId, processedAt: new Date() });

  res.status(200).json({ received: true });
});
Use the X-Webhook-Delivery-Id header to track which deliveries you’ve already processed.
Regularly rotate your webhook secrets for enhanced security:
1

Create a new webhook with the same events and URL

2

Update your application to support both old and new secrets temporarily

3

Verify the new webhook is working correctly

4

Delete the old webhook

5

Remove the old secret from your application

// 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) => {
    const expectedSignature = crypto
      .createHmac('sha256', secret)
      .update(req.rawBody)
      .digest('hex');

    try {
      return crypto.timingSafeEqual(
        Buffer.from(req.headers['x-webhook-signature']),
        Buffer.from(expectedSignature)
      );
    } catch {
      return false;
    }
  });
}

Webhook Payload Structure

AutoSend sends webhook payloads in this format:
{
  "type": "email.opened",
  "createdAt": "2025-01-08T10:30:00.000Z",
  "data": {
    "emailId": "email_abc123",
    "campaignId": "campaign_xyz789",
    "templateId": "template_def456",
    "from": "sender@example.com",
    "to": {
      "email": "recipient@example.com",
      "name": "John Doe"
    },
    "subject": "Welcome to AutoSend",
    "userAgent": "Mozilla/5.0...",
    "ipAddress": "192.168.1.1",
    "timestamp": "2025-01-08T10:30:00.000Z"
  }
}
type
string
required
The event type (e.g., "email.opened", "contact.created")
createdAt
string
required
ISO 8601 timestamp when the event occurred
data
object
required
Event-specific data (varies by event type)

Troubleshooting

Symptoms: All webhook requests return 401 UnauthorizedCommon Causes:
  1. Using the wrong secret
    // Debug: Check which secret you're using
    console.log('Secret starts with:', webhookSecret.substring(0, 10));
    console.log('Secret length:', webhookSecret.length); // Should be 64 chars
    
  2. Body parsing issues
    // Ensure you're using raw body
    console.log('Raw body:', req.rawBody);
    console.log('Raw body length:', req.rawBody.length);
    
  3. String encoding issues
    // Ensure consistent UTF-8 encoding
    const expectedSignature = crypto
      .createHmac('sha256', webhookSecret)
      .update(req.rawBody, 'utf8') // Explicit encoding
      .digest('hex');
    
  4. Comparing wrong values
    // Debug signature comparison
    console.log('Received signature:', receivedSignature);
    console.log('Expected signature:', expectedSignature);
    console.log(
      'Lengths match:',
      receivedSignature.length === expectedSignature.length
    );
    
  5. Secret contains whitespace
    // Trim whitespace from secret
    const webhookSecret = process.env.WEBHOOK_SECRET.trim();
    
Symptoms: Requests fail with “Invalid timestamp” errorCommon Causes:
  1. Wrong time unit - Timestamp is in milliseconds, not seconds
    // ❌ Bad - Treating as seconds
    const age = Math.floor(Date.now() / 1000) - parseInt(timestamp);
    
    // ✅ Good - Milliseconds
    const age = Date.now() - parseInt(timestamp);
    
  2. Clock skew - Server time is off
    // Allow 1 minute of clock skew
    const age = Date.now() - parseInt(timestamp);
    return age < 300000 && age > -60000; // -1 min to +5 min
    
  3. Timezone issues
    // Timestamps are always UTC
    const now = Date.now(); // Always use Date.now(), not local time
    
Test your signature verification without waiting for real webhooks:
const crypto = require('crypto');

function testSignatureVerification() {
  const webhookSecret = 'test-secret-12345';

  // Create a test payload (matching AutoSend's format)
  const payload = {
    type: 'email.opened',
    createdAt: new Date().toISOString(),
    data: {
      emailId: 'test-123',
      campaignId: 'campaign-456',
      timestamp: new Date().toISOString(),
    },
  };

  const payloadString = JSON.stringify(payload);

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

  console.log('Test payload:', payloadString);
  console.log('Test signature:', signature);

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

  console.log('Verification passes:', signature === expectedSignature);

  // Test with your actual verification function
  const isValid = crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  );

  console.log('timingSafeEqual passes:', isValid);
}

testSignatureVerification();
Symptoms: No webhook requests arriving at your endpointTroubleshooting Steps:
  1. Check webhook is active
    • Navigate to Webhooks in AutoSend
    • Verify webhook status is “Active”
    • Check if failure count is high (auto-disabled after 5 failures)
  2. Verify URL is accessible
    # Test your endpoint is publicly accessible
    curl -X POST https://your-domain.com/webhooks/autosend \
      -H "Content-Type: application/json" \
      -d '{"test": true}'
    
  3. Check delivery logs
    • Click on your webhook in AutoSend
    • View the “Delivery Logs” tab
    • Look for error messages or status codes
  4. Test with resend
    • Create a test event
    • Use the “Resend” feature to manually trigger delivery
    • Check your server logs

Next Steps