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