How to Authenticate Your Webhook Endpoint

When email-webhook fires your endpoint, the request comes from the internet. This guide explains the layers of protection available and how to validate them in your receiving server.

Layer 1: Keep your email address secret

Your incoming address — abc123xyz@email-webhook.com — is the first line of defence. The local part is a system-generated opaque ID, not a guessable name. Anyone who knows it can trigger your webhooks, so treat it like a password: don't commit it to public repos, don't paste it in public docs.

If you suspect the address has leaked, delete your webhooks and create new ones to get a fresh address.

Layer 2: Custom authentication headers

The second layer is a secret header that your endpoint checks on every request. You add it once in the dashboard; email-webhook sends it with every delivery.

In the webhook form, open the Custom Headers section and add a key-value pair:

Header name Header value
Authorization Bearer your-secret-token

Or equivalently with an API key header:

Header name Header value
X-Api-Key your-secret-token

You can add up to 5 custom headers per webhook.

Validating the header in your server

Node.js (Express)

app.post("/webhook", (req, res) => {
  if (req.headers["authorization"] !== "Bearer your-secret-token") {
    return res.sendStatus(401);
  }
  const { from, subject, message } = req.body;
  // handle email...
  res.sendStatus(200);
});

Python (Flask)

@app.post("/webhook")
def handle_email():
    if request.headers.get("Authorization") != "Bearer your-secret-token":
        return "", 401
    data = request.get_json()
    # handle email...
    return "", 200

Ruby (Sinatra)

post "/webhook" do
  halt 401 unless request.env["HTTP_AUTHORIZATION"] == "Bearer your-secret-token"
  data = JSON.parse(request.body.read)
  # handle email...
  status 200
end

Return 401 (or any non-2xx status) and email-webhook records the delivery as failed. The request is not retried if the status code is 4xx.

Layer 3: HMAC signature verification

The strongest layer is a signing secret. Unlike a header value, the secret is never sent over the wire — only a signature derived from it is. This proves both that the request came from email-webhook and that the body wasn't tampered with in transit.

Set it up: open your webhook's settings and fill in the Signing secret field (new webhooks get one generated automatically; leave it blank to disable signing). When a secret is set, every non-GET delivery includes two extra headers:

Header Contents
X-Timestamp Unix timestamp (seconds) when the request was sent
X-Signature-256 sha256=<hex-encoded HMAC-SHA256 signature>

The signature is computed over the string {timestamp}.{body} — the X-Timestamp value, a literal ., and the raw JSON body — using your secret as the HMAC key. GET webhooks carry no body and are never signed.

Verifying the signature in your server

Node.js (Express)

import crypto from "node:crypto";

app.post("/webhook", express.raw({ type: "*/*" }), (req, res) => {
  const secret = "your-signing-secret";
  const timestamp = req.headers["x-timestamp"];
  const signature = req.headers["x-signature-256"];

  const expected = "sha256=" + crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${req.body}`)
    .digest("hex");

  if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
    return res.sendStatus(401);
  }
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
    return res.sendStatus(401); // reject stale/replayed requests
  }

  const { from, subject, message } = JSON.parse(req.body);
  // handle email...
  res.sendStatus(200);
});

Python (Flask)

import hmac, hashlib, time

@app.post("/webhook")
def handle_email():
    secret = "your-signing-secret"
    timestamp = request.headers.get("X-Timestamp", "")
    signature = request.headers.get("X-Signature-256", "")

    signed = f"{timestamp}.{request.get_data(as_text=True)}"
    expected = "sha256=" + hmac.new(
        secret.encode(), signed.encode(), hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(expected, signature):
        return "", 401
    if abs(time.time() - int(timestamp)) > 300:
        return "", 401  # reject stale/replayed requests

    data = request.get_json()
    # handle email...
    return "", 200

Always use a constant-time comparison (crypto.timingSafeEqual, hmac.compare_digest) rather than ===/==: a naive comparison leaks timing information an attacker can use to guess the signature byte by byte.

The X-Timestamp freshness check is what makes this replay-resistant: even if an attacker captures a genuine request, replaying it later fails the age check. Five minutes is a reasonable window; tighten it if your endpoint is latency-sensitive.

On bearer tokens

Generally, prefer an API key over a bearer token. If you do need a bearer token, make sure it has a long enough duration (usually encoded as the exp parameter) so that your webhook requests succeed over time. You can learn more about bearer token at https://jwt.io

Using `X-email-webhook-id` as an idempotency key

Every delivery includes an X-email-webhook-id header containing a UUID unique to that request. Store it on the records you create and reject duplicates — this protects you if your server returns a non-2xx status but the processing already completed.

app.post("/webhook", async (req, res) => {
  if (req.headers["authorization"] !== "Bearer your-secret-token") {
    return res.sendStatus(401);
  }
  const deliveryId = req.headers["x-email-webhook-id"];
  if (await alreadyProcessed(deliveryId)) {
    return res.sendStatus(200); // acknowledge without re-processing
  }
  // handle email...
  res.sendStatus(200);
});