Skip to content

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:

code
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.

code
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

js
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

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.