Webhook signing

Webhook signing adds a cryptographic signature to each webhook request, allowing your server to verify that requests genuinely came from EmailConnect. This prevents attackers from sending forged webhook payloads to your endpoint.

How it works

When webhook signing is enabled:

  1. EmailConnect generates a unique secret key for your webhook
  2. For each request, EmailConnect creates an HMAC-SHA256 signature of the JSON request body
  3. The signature is included as an HTTP header; a timestamp header may also be sent
  4. Your server verifies the signature using the shared secret

Signature headers

EmailConnect sends the following headers with signed webhooks:

Header Description
X-Webhook-Signature HMAC-SHA256 signature in format sha256=<hex-signature>
X-Webhook-Timestamp Unix timestamp (seconds) when the request was sent. Informational only — not included in the signature computation and may not always be present

Enabling webhook signing

For new webhooks

  1. Create a new webhook in your dashboard
  2. Toggle "Webhook signing" in the Advanced options section
  3. Click "Create webhook"
  4. Copy the generated secret immediately - it won't be shown again

For existing webhooks

  1. Edit an existing webhook
  2. Toggle "Webhook signing" to enable it
  3. Click "Save"
  4. Copy the generated secret immediately

Regenerating a secret

If you need a new secret (e.g., after a potential compromise):

  1. Edit your webhook
  2. Click "Regenerate" next to the signing status
  3. Copy the new secret
  4. Update your server with the new secret

Verifying signatures

Step-by-step process

  1. Extract the X-Webhook-Signature header
  2. Get the raw request body as a string (before any JSON parsing)
  3. Compute HMAC-SHA256 of the raw body using your webhook secret
  4. Prepend sha256= to the hex digest
  5. Compare your computed signature with the received signature using a timing-safe comparison
  6. Optionally, check the X-Webhook-Timestamp header (if present) to reject old requests for replay protection

Node.js / Express

const crypto = require('crypto');

function verifyWebhookSignature(req, secret) {
  const signature = req.headers['x-webhook-signature'];

  if (!signature) {
    return false;
  }

  // Get raw body (requires express.raw() middleware or body-parser with verify)
  const rawBody = req.rawBody || JSON.stringify(req.body);

  const expectedSignature = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(rawBody, 'utf8')
    .digest('hex');

  // Use timing-safe comparison to prevent timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(signature, 'utf8'),
    Buffer.from(expectedSignature, 'utf8')
  );
}

// Express middleware
app.use('/webhook', express.json({
  verify: (req, res, buf) => {
    req.rawBody = buf.toString();
  }
}));

app.post('/webhook/emails', (req, res) => {
  const secret = process.env.WEBHOOK_SECRET;

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

  // Optional: check timestamp for replay protection
  const timestamp = req.headers['x-webhook-timestamp'];
  if (timestamp) {
    const currentTime = Math.floor(Date.now() / 1000);
    if (Math.abs(currentTime - parseInt(timestamp)) > 300) {
      console.error('Webhook timestamp too old');
      return res.status(401).json({ error: 'Request too old' });
    }
  }

  // Process verified webhook
  const emailData = req.body;
  console.log('Verified email from:', emailData.message.sender.email);

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

Python / Flask

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

app = Flask(__name__)
WEBHOOK_SECRET = os.environ.get('WEBHOOK_SECRET')

def verify_webhook_signature(request):
    signature = request.headers.get('X-Webhook-Signature')

    if not signature:
        return False

    # Get raw body
    raw_body = request.get_data(as_text=True)

    expected_signature = 'sha256=' + hmac.new(
        WEBHOOK_SECRET.encode(),
        raw_body.encode('utf-8'),
        hashlib.sha256
    ).hexdigest()

    # Use timing-safe comparison
    return hmac.compare_digest(signature, expected_signature)

@app.route('/webhook/emails', methods=['POST'])
def handle_webhook():
    if not verify_webhook_signature(request):
        return jsonify({'error': 'Invalid signature'}), 401

    # Optional: check timestamp for replay protection
    timestamp = request.headers.get('X-Webhook-Timestamp')
    if timestamp:
        current_time = int(time.time())
        if abs(current_time - int(timestamp)) > 300:
            return jsonify({'error': 'Request too old'}), 401

    email_data = request.json
    print(f"Verified email from: {email_data['message']['sender']['email']}")

    return jsonify({'success': True})

PHP

<?php

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

    $expectedSignature = 'sha256=' . hash_hmac('sha256', $payload, $secret);

    // Use timing-safe comparison
    return hash_equals($signature, $expectedSignature);
}

// Handle webhook
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$secret = getenv('WEBHOOK_SECRET');

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

// Optional: check timestamp for replay protection
$timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';
if (!empty($timestamp)) {
    $currentTime = time();
    if (abs($currentTime - intval($timestamp)) > 300) {
        http_response_code(401);
        echo json_encode(['error' => 'Request too old']);
        exit;
    }
}

$emailData = json_decode($payload, true);
error_log('Verified email from: ' . $emailData['message']['sender']['email']);

http_response_code(200);
echo json_encode(['success' => true]);

Ruby / Sinatra

require 'sinatra'
require 'json'
require 'openssl'

WEBHOOK_SECRET = ENV['WEBHOOK_SECRET']

def verify_webhook_signature(request)
  signature = request.env['HTTP_X_WEBHOOK_SIGNATURE']

  return false unless signature

  request.body.rewind
  raw_body = request.body.read

  expected_signature = 'sha256=' + OpenSSL::HMAC.hexdigest(
    'sha256',
    WEBHOOK_SECRET,
    raw_body
  )

  # Use timing-safe comparison
  Rack::Utils.secure_compare(signature, expected_signature)
end

post '/webhook/emails' do
  content_type :json

  unless verify_webhook_signature(request)
    status 401
    return { error: 'Invalid signature' }.to_json
  end

  # Optional: check timestamp for replay protection
  timestamp = request.env['HTTP_X_WEBHOOK_TIMESTAMP']
  if timestamp
    current_time = Time.now.to_i
    if (current_time - timestamp.to_i).abs > 300
      status 401
      return { error: 'Request too old' }.to_json
    end
  end

  request.body.rewind
  email_data = JSON.parse(request.body.read)
  puts "Verified email from: #{email_data['message']['sender']['email']}"

  { success: true }.to_json
end

Go

package main

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

var webhookSecret = os.Getenv("WEBHOOK_SECRET")

func verifyWebhookSignature(r *http.Request, body []byte) bool {
    signature := r.Header.Get("X-Webhook-Signature")

    if signature == "" {
        return false
    }

    h := hmac.New(sha256.New, []byte(webhookSecret))
    h.Write(body)
    expectedSignature := "sha256=" + hex.EncodeToString(h.Sum(nil))

    // Use constant-time comparison
    return hmac.Equal([]byte(signature), []byte(expectedSignature))
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    body, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "Failed to read body", http.StatusBadRequest)
        return
    }

    if !verifyWebhookSignature(r, body) {
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusUnauthorized)
        json.NewEncoder(w).Encode(map[string]string{"error": "Invalid signature"})
        return
    }

    // Optional: check timestamp for replay protection
    timestamp := r.Header.Get("X-Webhook-Timestamp")
    if timestamp != "" {
        ts, err := strconv.ParseInt(timestamp, 10, 64)
        if err == nil {
            currentTime := time.Now().Unix()
            if math.Abs(float64(currentTime-ts)) > 300 {
                w.Header().Set("Content-Type", "application/json")
                w.WriteHeader(http.StatusUnauthorized)
                json.NewEncoder(w).Encode(map[string]string{"error": "Request too old"})
                return
            }
        }
    }

    var emailData map[string]interface{}
    json.Unmarshal(body, &emailData)

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]bool{"success": true})
}

func main() {
    http.HandleFunc("/webhook/emails", webhookHandler)
    http.ListenAndServe(":8080", nil)
}

n8n (Code node)

If you receive webhooks in n8n, you can verify signatures using a Code node placed after your Webhook trigger node:

const crypto = require('crypto');
const secret = 'YOUR_WEBHOOK_SECRET';

const item = $input.first().json;
const signatureHeader = item.headers['x-webhook-signature'];

if (!signatureHeader) {
    throw new Error('Missing X-Webhook-Signature header');
}

const rawBody = JSON.stringify(item.body);
const expectedSignature = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(rawBody, 'utf8')
    .digest('hex');

const valid = crypto.timingSafeEqual(
    Buffer.from(signatureHeader, 'utf8'),
    Buffer.from(expectedSignature, 'utf8')
);

if (!valid) {
    throw new Error('Invalid webhook signature — request rejected');
}

return $input.all();

Security best practices

Store secrets securely

  • Use environment variables, never hardcode secrets
  • Use a secrets manager in production (AWS Secrets Manager, HashiCorp Vault, etc.)
  • Rotate secrets periodically or after any potential exposure

Verify timestamps

  • If the X-Webhook-Timestamp header is present, consider rejecting requests older than 5 minutes
  • This provides optional replay attack protection

Use timing-safe comparison

  • Always use constant-time string comparison (crypto.timingSafeEqual, hmac.compare_digest, etc.)
  • Regular string comparison can leak information through timing differences

Log verification failures

  • Log failed signature verifications for security monitoring
  • Include relevant details (timestamp, source IP) but never log the secret

Use HTTPS

  • Always use HTTPS for webhook endpoints
  • HTTPS encrypts the entire request including headers and signature

Troubleshooting

"Invalid signature" errors

Check your secret

  • Ensure you're using the correct secret for this webhook
  • Secrets are shown only once - regenerate if lost

Check raw body handling

  • The signature is computed on the raw JSON request body
  • Parsing JSON before verification can change whitespace/formatting
  • Use middleware that preserves the raw body

Check timestamp handling

  • The X-Webhook-Timestamp header is informational only and is not part of the signature
  • It may not be present on all requests
  • Do not include the timestamp in your signature computation

Testing signature verification

You can test your verification logic with a simple script:

const crypto = require('crypto');

const secret = 'your-webhook-secret';
const body = '{"test": "payload"}';

const signature = 'sha256=' + crypto
  .createHmac('sha256', secret)
  .update(body, 'utf8')
  .digest('hex');

console.log('X-Webhook-Signature:', signature);
console.log('Body:', body);

Need help?

Having issues with webhook signing?