Back to blog
Guides

Webhook HMAC Verification: A Developer's Guide

Deepak Sirone8 April 20267 min read

Webhooks let your application react to email events in real-time: a message was delivered, a recipient opened it, someone clicked a link, a hard bounce came back. Senthop sends these events to your endpoint as HTTP POST requests with a JSON payload.

The problem is straightforward: how does your server know that an incoming webhook request actually came from Senthop and was not forged by an attacker? Anyone who knows your webhook URL can send a POST request with a fake payload. Without verification, your application might process fraudulent delivery confirmations, fake bounce events, or — worse — accept attacker-controlled data into your database.

Our signing approach

Every webhook Senthop dispatches includes three headers:

X-Maielr-Signature: sha256=a1b2c3d4e5f6...
X-Maielr-Timestamp: 1713200000
X-Maielr-Webhook-Id: wh_evt_abc123

The signature is computed as follows:

  1. Construct the signed content: {timestamp}.{raw_request_body}
  2. Compute HMAC-SHA256 of that string using your webhook signing secret as the key.
  3. Hex-encode the result and prepend sha256=.

The timestamp is a Unix epoch (seconds). The webhook ID is a unique identifier for the event, useful for idempotency.

Why include the timestamp

Without a timestamp in the signed content, an attacker who intercepts a legitimate webhook payload can replay it indefinitely. By binding the timestamp into the signature, you can reject webhooks where the timestamp is too far from the current time.

We recommend a tolerance of 5 minutes (300 seconds). This accounts for network latency and minor clock drift without leaving a wide replay window.

Verification in Node.js

import { createHmac, timingSafeEqual } from 'node:crypto';

function verifyWebhook(req, secret) {
  const signature = req.headers['x-maielr-signature'];
  const timestamp = req.headers['x-maielr-timestamp'];
  const body = req.rawBody; // Must be the raw string, not parsed JSON

  // Check timestamp freshness (5-minute tolerance)
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (age > 300) {
    throw new Error('Webhook timestamp too old');
  }

  // Compute expected signature
  const signedContent = `${timestamp}.${body}`;
  const expected = 'sha256=' + createHmac('sha256', secret)
    .update(signedContent)
    .digest('hex');

  // Constant-time comparison to prevent timing attacks
  const a = Buffer.from(signature);
  const b = Buffer.from(expected);
  if (a.length !== b.length || !timingSafeEqual(a, b)) {
    throw new Error('Invalid webhook signature');
  }

  return JSON.parse(body);
}

Critical detail: You must verify against the raw request body string, not a serialised version of the parsed JSON. JSON serialisation is not deterministic — key order, whitespace, and Unicode escaping can differ between implementations. If you parse the body to JSON and then JSON.stringify() it, the signature will not match.

Verification in Python

import hmac
import hashlib
import time

def verify_webhook(headers, raw_body, secret):
    signature = headers.get('X-Maielr-Signature', '')
    timestamp = headers.get('X-Maielr-Timestamp', '')

    # Check timestamp freshness
    age = abs(time.time() - int(timestamp))
    if age > 300:
        raise ValueError('Webhook timestamp too old')

    # Compute expected signature
    signed_content = f'{timestamp}.{raw_body}'
    expected = 'sha256=' + hmac.new(
        secret.encode(),
        signed_content.encode(),
        hashlib.sha256
    ).hexdigest()

    # Constant-time comparison
    if not hmac.compare_digest(signature, expected):
        raise ValueError('Invalid webhook signature')

    return True

In Flask, access the raw body with request.get_data(as_text=True). In Django, use request.body.decode('utf-8'). Do not use request.json — that parses and re-serialises.

Verification in Go

package main

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

func verifyWebhook(r *http.Request, secret string) ([]byte, error) {
    signature := r.Header.Get("X-Maielr-Signature")
    timestamp := r.Header.Get("X-Maielr-Timestamp")

    // Read raw body
    body, err := io.ReadAll(r.Body)
    if err != nil {
        return nil, fmt.Errorf("failed to read body: %w", err)
    }

    // Check timestamp freshness
    ts, err := strconv.ParseInt(timestamp, 10, 64)
    if err != nil {
        return nil, fmt.Errorf("invalid timestamp: %w", err)
    }
    age := math.Abs(float64(time.Now().Unix() - ts))
    if age > 300 {
        return nil, fmt.Errorf("webhook timestamp too old")
    }

    // Compute expected signature
    signedContent := fmt.Sprintf("%s.%s", timestamp, string(body))
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(signedContent))
    expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))

    // Constant-time comparison
    if !hmac.Equal([]byte(signature), []byte(expected)) {
        return nil, fmt.Errorf("invalid webhook signature")
    }

    return body, nil
}

Testing webhooks locally

During development, your local server is not reachable from the internet. There are several approaches:

1. Use a tunnel. Tools like ngrok, cloudflared tunnel, or tailscale funnel expose a local port via a public URL. Point your Senthop webhook configuration to the tunnel URL. This is the most realistic approach because you receive actual signed payloads.

2. Use the Senthop CLI. Our CLI tool includes a maielr webhooks listen command that opens a WebSocket connection to our servers and forwards webhook events to a local URL. No tunnel setup required.

3. Replay from logs. The Senthop dashboard shows recent webhook deliveries with the full payload and headers. You can copy these and replay them locally with curl. The signatures will still verify as long as you use the same webhook secret and account for the timestamp tolerance.

# Replay a webhook delivery locally
curl -X POST http://localhost:3000/webhooks/maielr \
  -H "Content-Type: application/json" \
  -H "X-Maielr-Signature: sha256=abc123..." \
  -H "X-Maielr-Timestamp: 1713200000" \
  -H "X-Maielr-Webhook-Id: wh_evt_abc123" \
  -d '{"event":"delivered","message_id":"msg_xyz"}'

Note that replayed webhooks will fail the timestamp check if the original event is more than 5 minutes old. For local testing, you can temporarily disable the timestamp check — but never in production.

Common pitfalls

  • Parsed vs. raw body: This is the number one cause of verification failures. Use the raw request body.
  • String comparison instead of constant-time: Using === (JS), == (Python), or == (Go) for signature comparison leaks timing information. Always use the constant-time comparison functions shown above.
  • Ignoring the timestamp: Without the freshness check, you are vulnerable to replay attacks.
  • Secret rotation: When you rotate your webhook secret in the Senthop dashboard, there is a brief overlap period where we send both the old and new signatures. Your verification code should accept either during this window.

Webhook verification is a few lines of code, but it is a critical security boundary. Every webhook endpoint you expose should verify signatures before processing the payload. No exceptions.

Try it yourself

Try Senthop free — send your first email in 5 minutes

UK-built and hosted email infrastructure with automatic DKIM, SPF, and DMARC. No credit card required.

Start free