Verify signed webhook deliveries
MailWebhook signs every webhook delivery request with X-MailWebhook-Signature.
Use this page when your receiving endpoint needs to prove that a request came from MailWebhook and that the request body was not changed before it reached your application.
Building a receiving service for MailWebhook requests? See Email Webhook API for product context. For endpoint setup, static headers, and URL restrictions, see Endpoints.
Table of contents
- Header format
- What is signed
- Verification steps
- Verify the shared fixture in the browser
- Python verifier
- Node.js verifier
- Next step: build your receiver
- Common verification mistakes
- Inspect a signed request
- Related docs
Header format
MailWebhook adds this header to delivery requests:
X-MailWebhook-Signature: t=<unix>, kid=<kid>, v1=<base64_hmac_sha256>
| Part | Meaning |
|---|---|
t | Unix timestamp in seconds. |
kid | Key identifier for the signing secret used by the route. |
v1 | Base64-encoded HMAC-SHA256 digest. |
The signature header is generated for initial delivery attempts, retries, and event replays.
What is signed
Verify the signature against the exact raw HTTP request body bytes.
The HMAC input is:
<t>.<raw_request_body_bytes>
In code, that means:
signed_input = ascii(timestamp) + "." + raw_request_body_bytes
expected = hmac_sha256(signing_secret, signed_input)
Do not verify against parsed JSON, pretty-printed JSON, decoded text, a request object, or a reserialized copy of the body. JSON parsing can change whitespace and field ordering, which changes the bytes that were signed.
Verification steps
- Read the raw request body bytes before your framework parses or mutates the body.
- Parse
X-MailWebhook-Signatureintot,kid, andv1. - Select the signing secret for the route by
kid. - Build the signed input as
f"{t}.".encode("ascii") + body_bytes. - Compute
HMAC-SHA256with the selected signing secret. - Base64-decode
v1. - Compare the expected digest and provided digest with a constant-time comparison.
- Reject timestamps outside your receiver’s accepted replay window.
Use a replay window that matches your system’s tolerance for delayed delivery attempts. A common starting point is 300 seconds.
Verify the shared fixture in the browser
Use the HMAC Signature Verifier when you want to check the signature contract before writing receiver code. Choose MailWebhook signature mode. That mode signs timestamp + "." + raw body and compares the result with the base64 v1 value from X-MailWebhook-Signature.
Click Load MailWebhook example. The tool loads the same synthetic fixture that this docs site keeps under /assets/examples/signatures/.
Expected result with the fixed sample clock:
| Field | Expected value |
|---|---|
| Signature result | Verified |
| Timestamp status | Fresh |
| Delivery decision | Accepted delivery |
kid | mw_test_kid_v1 |
| Signature timestamp | 1700000000 (2023-11-14T22:13:20Z) |
| Verification time | 1700000120 (2023-11-14T22:15:20Z) |
| Tolerance | 300 seconds |
Then click Use current time. The same historical sample should remain cryptographically Verified, but its timestamp status should change to Stale, and the delivery decision should say it is not a current accepted delivery.
Append one space to the raw body and verify again. The result should change to Signature mismatch, because the exact body bytes changed.
Fixture provenance:
- Fixture version:
mailwebhook-signature-v1 - Source commit:
3e11346ba25e5181cfb4f103b0a8ca558fa580cainmailwebhookhq/examples - Body file:
mailwebhook-body-v1.json - Fixture manifest:
mailwebhook-signature-v1.json - Provenance manifest:
manifest.json - Body SHA-256:
db71198e31397a66e7cead6e77a841ea1f98c25f5ea58fbbb423de6fe5e11445
The fixture secret is synthetic and test-only. In production, use the route signing secret selected by kid. That secret is separate from MailWebhook API keys, endpoint custom headers, and mailbox provider credentials.
Python verifier
This example expects body to be the raw request body bytes and secrets_by_kid to map key ids to secret bytes.
import base64
import binascii
import hashlib
import hmac
import time
def verify_mailwebhook_signature(
header: str,
body: bytes,
secrets_by_kid: dict[str, bytes],
tolerance_seconds: int = 300,
) -> bool:
parts: dict[str, str] = {}
for item in header.split(","):
if "=" not in item:
continue
key, value = item.strip().split("=", 1)
parts[key] = value
try:
timestamp = int(parts["t"])
kid = parts["kid"]
signature_b64 = parts["v1"]
except (KeyError, ValueError):
return False
if abs(int(time.time()) - timestamp) > tolerance_seconds:
return False
secret = secrets_by_kid.get(kid)
if secret is None:
return False
signed_input = f"{timestamp}.".encode("ascii") + body
expected = hmac.new(secret, signed_input, hashlib.sha256).digest()
try:
provided = base64.b64decode(signature_b64, validate=True)
except (binascii.Error, ValueError):
return False
return hmac.compare_digest(expected, provided)
Node.js verifier
This example expects bodyBuffer to be the raw request body buffer and secretsByKid to map key ids to signing secrets.
import crypto from "node:crypto";
export function verifyMailWebhookSignature(
header,
bodyBuffer,
secretsByKid,
toleranceSeconds = 300,
) {
const parts = Object.fromEntries(
header
.split(",")
.map((part) => part.trim())
.filter((part) => part.includes("="))
.map((part) => {
const index = part.indexOf("=");
return [part.slice(0, index), part.slice(index + 1)];
}),
);
if (!/^\d+$/.test(parts.t ?? "")) {
return false;
}
const timestamp = Number.parseInt(parts.t, 10);
const kid = parts.kid;
const signatureB64 = parts.v1;
if (!Number.isFinite(timestamp) || !kid || !signatureB64) {
return false;
}
const nowSeconds = Math.floor(Date.now() / 1000);
if (Math.abs(nowSeconds - timestamp) > toleranceSeconds) {
return false;
}
const secret = secretsByKid[kid];
if (!secret) {
return false;
}
const signedInput = Buffer.concat([
Buffer.from(`${timestamp}.`, "ascii"),
bodyBuffer,
]);
const expected = crypto
.createHmac("sha256", secret)
.update(signedInput)
.digest();
let provided;
try {
provided = Buffer.from(signatureB64, "base64");
} catch {
return false;
}
return (
expected.length === provided.length &&
crypto.timingSafeEqual(expected, provided)
);
}
In Express, use a raw body parser for the webhook route so req.body remains the exact bytes MailWebhook signed:
app.post(
"/mailwebhook",
express.raw({ type: "application/json" }),
(req, res) => {
const ok = verifyMailWebhookSignature(
req.get("X-MailWebhook-Signature") || "",
req.body,
secretsByKid,
);
if (!ok) {
res.sendStatus(401);
return;
}
const payload = JSON.parse(req.body.toString("utf8"));
processPayload(payload);
res.sendStatus(204);
},
);
Next step: build your receiver
After you choose a verifier pattern, use Build your application receiver for complete Node.js, Python, and Go receiver examples. The guide shows how to capture the raw body, verify the signature, process the payload, and return a successful webhook response.
Common verification mistakes
- Using parsed JSON instead of raw request body bytes.
- Comparing a hex digest to
v1, which is base64 encoded. - Including the HTTP method, URL, headers, or response body in the HMAC input.
- Using a MailWebhook API key instead of the route signing secret.
- Ignoring
kidwhen more than one signing secret exists. - Letting a web framework read the body before signature verification.
- Accepting very old timestamps without a receiver-side replay window.
Inspect a signed request
Use Webhook Preview when you are testing a route from onboarding. The Request tab shows the outgoing request headers and body for the captured delivery.
Use Events and Delivery Attempts History when you need to inspect delivery status, response status, response body preview, or replay behavior.