Webhook signing
The signing secret, the X-Mail-Signature header, and how to verify a request in Node and Python.
reference~3 minVerified v0.216.0
Every Mail webhook request is signed, so a receiver can tell it came from Mail and was not changed. Set the webhook first: see Mail webhooks. Mail signs with its own scheme, not the Standard Webhooks one Super Artifacts webhooks use, so a verifier for that scheme will not work here.
The secret
mail_webhook_set shows the signing secret once, in its answer. Store it then. There is one secret for each mailbox. Calling mail_webhook_set again makes a new secret and retires the old one, and each answer carries a version that moves when the secret does. The secret is a lower case hex string. Use its text as the key, exactly as shown.
The header
Each request has an X-Mail-Signature header:
t=1731100000,v1=3f5a…
t is the Unix time in seconds when the request was signed, and v1 is the lower case hex HMAC SHA256 of the timestamp, a dot, and the raw request body, keyed by the secret.
v1 = hex( HMAC_SHA256( secret, t + "." + rawBody ) )
Verify the bytes exactly as received and parse the JSON afterwards. A body parsed and written out again can differ, and then the signature fails. Refuse a request whose timestamp is more than five minutes from your clock, and compare in constant time.
Node
import { createHmac, timingSafeEqual } from 'node:crypto' // rawBody: the request body exactly as received, as a string. export function verify(secret, header, rawBody) { const parts = Object.fromEntries(header.split(',').map((part) => part.trim().split('='))) const t = Number(parts.t) if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > 300) return false const expected = createHmac('sha256', secret).update(t + "." + rawBody).digest('hex') const given = parts.v1 ?? '' return given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected)) }
Python
import hashlib, hmac, time def verify(secret: str, header: str, raw_body: bytes) -> bool: parts = dict(part.strip().split("=", 1) for part in header.split(",")) try: t = int(parts["t"]) except (KeyError, ValueError): return False if abs(time.time() - t) > 300: return False signed = f"{t}.".encode() + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return hmac.compare_digest(parts.get("v1", ""), expected)
When verification fails, answer with a 4xx other than 408 or 429. Mail treats that as final and does not retry. See Webhook events for the retry rules.