Webhooks — receive events, verified
A webhook pushes events to your endpoint as they happen, instead of you polling for them. Register an HTTPS URL, choose which event types you want, and mails.ai POSTs each matching event — signed, retried, and logged.
Register an endpoint
POST /v1/webhooks (scope manage). The url must be https:// and is checked against SSRF (private, loopback, and metadata addresses are rejected). event_types defaults to ["*"]: every event except thread.created, thread.updated, thread.deleted and message.delayed, which an endpoint receives only when it names them:
curl https://api.mails.ai/v1/webhooks \
-H "Authorization: Bearer $MAILS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.com/webhooks/mails",
"event_types": ["message.received", "message.bounced"],
"description": "prod inbound handler"
}'The response includes a signing_secret (whsec_…), shown once. Store it — you need it to verify every delivery.
{
"id": "whe_01J9X2K7Q8...",
"url": "https://your-app.com/webhooks/mails",
"signing_secret": "whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"event_types": ["message.received", "message.bounced"],
"description": "prod inbound handler",
"active": true,
"created_at": "2026-06-19T18:10:00.000Z"
}Delivery headers
Each delivery is a POST with the event as the JSON body and these headers:
X-Mails-Signature—t=<unix>,v1=<hmac_hex>. For 24 hours after you rotate the signing secret it carries twov1signatures, one from each secret.X-Mails-Event-Id— the event id (use it to dedupe).X-Mails-Event-Type— e.g.message.received.User-Agent—Mails.ai/1.0.
Verify the signature
The signature is HMAC-SHA256(signing_secret, "<t>.<raw_body>") as hex. Recompute it over the raw request body (not a re-serialized object), compare it in constant time with every v1 in the header, accept the delivery when any of them matches, and reject it if the timestamp is more than 300 seconds old. In Node:
import crypto from "node:crypto";
function verify(rawBody: string, header: string, secret: string): boolean {
const parts = header.split(",").map((kv) => kv.split("=") as [string, string]);
const t = Number(parts.find(([k]) => k === "t")?.[1]);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false; // replay window
const expected = Buffer.from(
crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"),
);
// Two v1 signatures for 24 hours after a secret rotation: accept either.
return parts.some(([k, v]) => {
if (k !== "v1" || !v) return false;
const got = Buffer.from(v);
return got.length === expected.length && crypto.timingSafeEqual(got, expected);
});
}
// Express: capture the RAW body, then verify before parsing.
app.post("/webhooks/mails", express.raw({ type: "application/json" }), (req, res) => {
const raw = req.body.toString("utf8");
if (!verify(raw, req.header("X-Mails-Signature") ?? "", process.env.MAILS_WEBHOOK_SECRET!)) {
return res.status(400).send("bad signature");
}
const event = JSON.parse(raw);
// ... handle event, then:
res.status(200).end();
});The same verification in Python:
import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = [kv.split("=", 1) for kv in header.split(",") if "=" in kv]
t = int(next((v for k, v in parts if k == "t"), 0))
if not t or abs(time.time() - t) > 300: # replay window
return False
expected = hmac.new(
secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256
).hexdigest()
# Two v1 signatures for 24 hours after a secret rotation: accept either.
return any(hmac.compare_digest(expected, v) for k, v in parts if k == "v1")Respond fast, then work
Return a 2xx as soon as you have verified and durably enqueued the event. Anything else — non-2xx, a timeout, a connection error — counts as a failed delivery. Do slow work (LLM calls, downstream sends) after you have responded, off the request path.
Retries and the dead-letter queue
Delivery is attempted inline first; failures are retried on a backoff (about 30s, then 120s), up to three attempts. After the final failure the delivery is parked in a dead-letter state (dlq). Because retries mean an event can arrive more than once, make your handler idempotent — dedupe on X-Mails-Event-Id.
Inspect, test, replay
POST /v1/webhooks/{id}/test— fire a syntheticwebhook.testevent at the endpoint and get back the HTTP status and a response excerpt. The fastest way to confirm wiring.GET /v1/webhooks/{id}/deliveries— the delivery log: attempt number, status (pending/succeeded/failed/dlq), HTTP status, response excerpt, next retry time.POST /v1/webhook-deliveries/{id}/replay— replay a single delivery once your endpoint is healthy again.
Common questions
Was this page helpful?