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 });
});

from flask import Flask, request, jsonify
import hmac
import hashlib
import os

app = Flask(__name__)
WEBHOOK_SECRET = os.getenv('WEBHOOK_SECRET')

def verify_webhook_signature(payload, signature, secret):
    """Verify webhook signature using HMAC-SHA256"""
    if not signature:
        return False

    # Compute expected signature
    expected = hmac.new(
        secret.encode('utf-8'),
        payload.encode('utf-8'),
        hashlib.sha256
    ).hexdigest()

    # Constant-time comparison
    return hmac.compare_digest(expected, signature)

@app.route('/webhooks/autosend', methods=['POST'])
def webhook():
    # Get signature from header
    signature = request.headers.get('X-Webhook-Signature')

    # Get raw payload
    payload = request.get_data(as_text=True)

    # Verify signature
    if not verify_webhook_signature(payload, signature, WEBHOOK_SECRET):
        return jsonify({'error': 'Invalid signature'}), 401

    # Parse and process webhook
    data = request.json
    event_type = data.get('type')
    print(f'Verified webhook: {event_type}')

    return jsonify({'received': True}), 200

if __name__ == '__main__':
    app.run(port=3000)
<?php
function verifyWebhookSignature($payload, $receivedSignature, $secret) {
    if (empty($receivedSignature)) {
        return false;
    }

    // Compute expected signature
    $expectedSignature = hash_hmac('sha256', $payload, $secret);

    // Constant-time comparison
    return hash_equals($expectedSignature, $receivedSignature);
}

// Get raw POST body
$payload = file_get_contents('php://input');

// Get signature from header
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';

// Get secret from environment
$webhookSecret = getenv('WEBHOOK_SECRET');

// Verify signature
if (!verifyWebhookSignature($payload, $signature, $webhookSecret)) {
    http_response_code(401);
    echo json_encode(['error' => 'Invalid signature']);
    exit;
}

// Parse and process webhook
$data = json_decode($payload, true);
$eventType = $data['type'];

error_log("Verified webhook: $eventType");

http_response_code(200);
echo json_encode(['received' => true]);
?>
require 'sinatra'
require 'json'
require 'openssl'

WEBHOOK_SECRET = ENV['WEBHOOK_SECRET']

def verify_webhook_signature(payload, signature, secret)
  return false if signature.nil? || signature.empty?

  # Compute expected signature
  expected = OpenSSL::HMAC.hexdigest('sha256', secret, payload)

  # Constant-time comparison
  Rack::Utils.secure_compare(expected, signature)
end

post '/webhooks/autosend' do
  # Get raw body and signature
  payload = request.body.read
  signature = request.env['HTTP_X_WEBHOOK_SIGNATURE']

  # Verify signature
  unless verify_webhook_signature(payload, signature, WEBHOOK_SECRET)
    status 401
    return { error: 'Invalid signature' }.to_json
  end

  # Parse and process webhook
  data = JSON.parse(payload)
  event_type = data['type']

  puts "Verified webhook: #{event_type}"

  status 200
  { received: true }.to_json
end
package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "io/ioutil"
    "net/http"
    "os"
)

func verifyWebhookSignature(payload []byte, signature string, secret string) bool {
    if signature == "" {
        return false
    }

    // Compute expected signature
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(payload)
    expected := hex.EncodeToString(mac.Sum(nil))

    // Constant-time comparison
    return hmac.Equal([]byte(expected), []byte(signature))
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    // Read raw body
    payload, err := ioutil.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "Error reading body", http.StatusBadRequest)
        return
    }

    // Get signature from header
    signature := r.Header.Get("X-Webhook-Signature")

    // Get secret from environment
    webhookSecret := os.Getenv("WEBHOOK_SECRET")

    // Verify signature
    if !verifyWebhookSignature(payload, signature, webhookSecret) {
        http.Error(w, "Invalid signature", http.StatusUnauthorized)
        return
    }

    // Parse webhook
    var data map[string]interface{}
    json.Unmarshal(payload, &data)

    // Process webhook
    eventType := data["type"].(string)
    println("Verified webhook:", eventType)

    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(map[string]bool{"received": true})
}

func main() {
    http.HandleFunc("/webhooks/autosend", webhookHandler)
    http.ListenAndServe(":3000", nil)
}

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:
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");
});

from flask import Flask, request, jsonify
import hmac
import hashlib
import os
import time
import json
import logging

app = Flask(__name__)
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

WEBHOOK_SECRET = os.getenv('WEBHOOK_SECRET')

if not WEBHOOK_SECRET:
    raise ValueError("WEBHOOK_SECRET environment variable is required")

def verify_webhook_signature(payload, signature, secret):
    """Verify webhook signature using HMAC-SHA256"""
    if not signature:
        return False

    expected = hmac.new(
        secret.encode('utf-8'),
        payload.encode('utf-8'),
        hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(expected, signature)

def is_timestamp_valid(timestamp, max_age_seconds=300):
    """Validate webhook timestamp"""
    if not timestamp:
        return False

    try:
        now = int(time.time() * 1000)
        ts = int(timestamp)
        age = now - ts

        # Reject if older than 5 minutes or more than 1 minute in the future
        return age < max_age_seconds * 1000 and age > -60000
    except (ValueError, TypeError):
        return False

@app.route('/webhooks/autosend', methods=['POST'])
def webhook():
    # Get headers
    delivery_id = request.headers.get('X-Webhook-Delivery-Id')
    timestamp = request.headers.get('X-Webhook-Timestamp')
    event = request.headers.get('X-Webhook-Event')
    signature = request.headers.get('X-Webhook-Signature')

    # Get raw payload
    payload = request.get_data(as_text=True)

    # Validate timestamp
    if not is_timestamp_valid(timestamp):
        logger.error(f"Invalid timestamp: {delivery_id}, {timestamp}")
        return jsonify({'error': 'Invalid timestamp'}), 401

    # Verify signature
    if not verify_webhook_signature(payload, signature, WEBHOOK_SECRET):
        logger.error(f"Invalid signature: {delivery_id}, {event}")
        return jsonify({'error': 'Invalid signature'}), 401

    # Parse and process webhook
    data = request.json
    event_type = data.get('type')
    event_data = data.get('data')
    created_at = data.get('createdAt')

    logger.info(f"Webhook received and verified: {delivery_id}, {event_type}")

    try:
        # Process webhook asynchronously
        process_webhook_async(event_type, event_data)
        return jsonify({'received': True}), 200
    except Exception as error:
        logger.error(f"Error processing webhook: {delivery_id}, {error}")
        # Still return 200 to avoid retries
        return jsonify({'received': True, 'warning': 'Processing queued'}), 200

def process_webhook_async(event_type, data):
    """Process webhook in background"""
    logger.info(f"Processing {event_type} event: {data}")
    # Implement your webhook processing logic here

if __name__ == '__main__':
    app.run(port=3000)
<?php

error_reporting(E_ALL);
ini_set('display_errors', 1);

$webhookSecret = getenv('WEBHOOK_SECRET');

if (!$webhookSecret) {
    throw new Exception("WEBHOOK_SECRET environment variable is required");
}

function verifyWebhookSignature($payload, $receivedSignature, $secret) {
    if (empty($receivedSignature)) {
        return false;
    }

    $expectedSignature = hash_hmac('sha256', $payload, $secret);
    return hash_equals($expectedSignature, $receivedSignature);
}

function isTimestampValid($timestamp, $maxAgeSeconds = 300) {
    if (empty($timestamp)) {
        return false;
    }

    $now = round(microtime(true) * 1000);
    $ts = (int)$timestamp;
    $age = $now - $ts;

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

// Get raw POST body
$payload = file_get_contents('php://input');

// Get headers
$deliveryId = $_SERVER['HTTP_X_WEBHOOK_DELIVERY_ID'] ?? '';
$timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';
$event = $_SERVER['HTTP_X_WEBHOOK_EVENT'] ?? '';
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';

// Validate timestamp
if (!isTimestampValid($timestamp)) {
    error_log("Invalid timestamp: {$deliveryId}, {$timestamp}");
    http_response_code(401);
    echo json_encode(['error' => 'Invalid timestamp']);
    exit;
}

// Verify signature
if (!verifyWebhookSignature($payload, $signature, $webhookSecret)) {
    error_log("Invalid signature: {$deliveryId}, {$event}");
    http_response_code(401);
    echo json_encode(['error' => 'Invalid signature']);
    exit;
}

// Parse and process webhook
$data = json_decode($payload, true);
$eventType = $data['type'] ?? '';
$eventData = $data['data'] ?? [];
$createdAt = $data['createdAt'] ?? '';

error_log("Webhook received and verified: {$deliveryId}, {$eventType}");

try {
    // Process webhook
    processWebhookAsync($eventType, $eventData);

    http_response_code(200);
    echo json_encode(['received' => true]);
} catch (Exception $error) {
    error_log("Error processing webhook: {$deliveryId}, {$error->getMessage()}");
    // Still return 200 to avoid retries
    http_response_code(200);
    echo json_encode(['received' => true, 'warning' => 'Processing queued']);
}

function processWebhookAsync($eventType, $data) {
    error_log("Processing {$eventType} event");
    // Implement your webhook processing logic here
}
?>
require 'sinatra'
require 'json'
require 'openssl'
require 'time'
require 'logger'

WEBHOOK_SECRET = ENV['WEBHOOK_SECRET']

if WEBHOOK_SECRET.nil? || WEBHOOK_SECRET.empty?
  raise "WEBHOOK_SECRET environment variable is required"
end

logger = Logger.new(STDOUT)

def verify_webhook_signature(payload, signature, secret)
  return false if signature.nil? || signature.empty?

  expected = OpenSSL::HMAC.hexdigest('sha256', secret, payload)
  Rack::Utils.secure_compare(expected, signature)
end

def is_timestamp_valid(timestamp, max_age_seconds = 300)
  return false if timestamp.nil? || timestamp.empty?

  now = (Time.now.to_f * 1000).to_i
  ts = timestamp.to_i
  age = now - ts

  # Reject if older than 5 minutes or more than 1 minute in the future
  age < (max_age_seconds * 1000) && age > -60000
end

post '/webhooks/autosend' do
  # Get headers
  delivery_id = request.env['HTTP_X_WEBHOOK_DELIVERY_ID']
  timestamp = request.env['HTTP_X_WEBHOOK_TIMESTAMP']
  event = request.env['HTTP_X_WEBHOOK_EVENT']
  signature = request.env['HTTP_X_WEBHOOK_SIGNATURE']

  # Get raw body
  payload = request.body.read

  # Validate timestamp
  unless is_timestamp_valid(timestamp)
    logger.error("Invalid timestamp: #{delivery_id}, #{timestamp}")
    status 401
    return { error: 'Invalid timestamp' }.to_json
  end

  # Verify signature
  unless verify_webhook_signature(payload, signature, WEBHOOK_SECRET)
    logger.error("Invalid signature: #{delivery_id}, #{event}")
    status 401
    return { error: 'Invalid signature' }.to_json
  end

  # Parse and process webhook
  data = JSON.parse(payload)
  event_type = data['type']
  event_data = data['data']
  created_at = data['createdAt']

  logger.info("Webhook received and verified: #{delivery_id}, #{event_type}")

  begin
    # Process webhook asynchronously
    process_webhook_async(event_type, event_data)
    status 200
    { received: true }.to_json
  rescue => error
    logger.error("Error processing webhook: #{delivery_id}, #{error.message}")
    # Still return 200 to avoid retries
    status 200
    { received: true, warning: 'Processing queued' }.to_json
  end
end

def process_webhook_async(event_type, data)
  # Implement your webhook processing logic here
  puts "Processing #{event_type} event: #{data}"
end
package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "io/ioutil"
    "log"
    "net/http"
    "os"
    "strconv"
    "time"
)

var webhookSecret string

func init() {
    webhookSecret = os.Getenv("WEBHOOK_SECRET")
    if webhookSecret == "" {
        log.Fatal("WEBHOOK_SECRET environment variable is required")
    }
}

func verifyWebhookSignature(payload []byte, signature string, secret string) bool {
    if signature == "" {
        return false
    }

    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(payload)
    expected := hex.EncodeToString(mac.Sum(nil))

    return hmac.Equal([]byte(expected), []byte(signature))
}

func isTimestampValid(timestamp string, maxAgeSeconds int) bool {
    if timestamp == "" {
        return false
    }

    ts, err := strconv.ParseInt(timestamp, 10, 64)
    if err != nil {
        return false
    }

    now := time.Now().UnixMilli()
    age := now - ts

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

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    // Read raw body
    payload, err := ioutil.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "Error reading body", http.StatusBadRequest)
        return
    }

    // Get headers
    deliveryId := r.Header.Get("X-Webhook-Delivery-Id")
    timestamp := r.Header.Get("X-Webhook-Timestamp")
    event := r.Header.Get("X-Webhook-Event")
    signature := r.Header.Get("X-Webhook-Signature")

    // Validate timestamp
    if !isTimestampValid(timestamp, 300) {
        log.Printf("Invalid timestamp: %s, %s", deliveryId, timestamp)
        http.Error(w, "Invalid timestamp", http.StatusUnauthorized)
        return
    }

    // Verify signature
    if !verifyWebhookSignature(payload, signature, webhookSecret) {
        log.Printf("Invalid signature: %s, %s", deliveryId, event)
        http.Error(w, "Invalid signature", http.StatusUnauthorized)
        return
    }

    // Parse webhook
    var data map[string]interface{}
    if err := json.Unmarshal(payload, &data); err != nil {
        http.Error(w, "Invalid JSON", http.StatusBadRequest)
        return
    }

    eventType, _ := data["type"].(string)
    eventData, _ := data["data"].(map[string]interface{})
    createdAt, _ := data["createdAt"].(string)

    log.Printf("Webhook received and verified: %s, %s, %s", deliveryId, eventType, createdAt)

    // Process webhook
    if err := processWebhookAsync(eventType, eventData); err != nil {
        log.Printf("Error processing webhook: %s, %v", deliveryId, err)
        // Still return 200 to avoid retries
    }

    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(map[string]bool{"received": true})
}

func processWebhookAsync(eventType string, data map[string]interface{}) error {
    // Implement your webhook processing logic here
    log.Printf("Processing %s event: %v", eventType, data)
    return nil
}

func main() {
    http.HandleFunc("/webhooks/autosend", webhookHandler)
    log.Println("Webhook server listening on port 3000")
    log.Fatal(http.ListenAndServe(":3000", nil))
}

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": "[email protected]",
    "to": {
      "email": "[email protected]",
      "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

https://mintcdn.com/autosend-13920f5c/nx_wYfWx3qeZwg1C/icons/event-types.svg?fit=max&auto=format&n=nx_wYfWx3qeZwg1C&q=85&s=7e37cf26e7cf6375a72cf73483295f3f

Event Types

Learn about all available webhook events and their payloads
https://mintcdn.com/autosend-13920f5c/nx_wYfWx3qeZwg1C/icons/retries-refresh.svg?fit=max&auto=format&n=nx_wYfWx3qeZwg1C&q=85&s=6c8bfc5edaa01d3624986eebbb4cbb09

Retries and Replays

Understand how AutoSend handles failed deliveries
https://mintcdn.com/autosend-13920f5c/6j5m-Y2eck3vmZRR/icons/best-practices.svg?fit=max&auto=format&n=6j5m-Y2eck3vmZRR&q=85&s=69778574ae9457e8006f9187de6757a4

Best Practices

Production deployment guidelines and optimization tips
https://mintcdn.com/autosend-13920f5c/nx_wYfWx3qeZwg1C/icons/webhook.svg?fit=max&auto=format&n=nx_wYfWx3qeZwg1C&q=85&s=14ad6675c71731ac04f786559a813ee1

Manage Webhooks

Configure and monitor your webhooks in AutoSend

https://mintcdn.com/autosend-13920f5c/nx_wYfWx3qeZwg1C/icons/introduction.svg?fit=max&auto=format&n=nx_wYfWx3qeZwg1C&q=85&s=9b4ef691e39d4517b800d381b32a89db

Introduction

Getting started with AutoSend webhooks
https://mintcdn.com/autosend-13920f5c/nx_wYfWx3qeZwg1C/icons/email-activity.svg?fit=max&auto=format&n=nx_wYfWx3qeZwg1C&q=85&s=2ecad7369f217ee7d03c3d8dfdd36d22

Delivery Logs

View webhook delivery history and debug issues