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 ["*"]— all events:
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>.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 in constant time, and reject 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 = Object.fromEntries(
header.split(",").map((kv) => kv.split("=") as [string, string]),
);
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false; // replay window
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 ?? "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// 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 = dict(kv.split("=", 1) for kv in header.split(","))
t = int(parts.get("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()
return hmac.compare_digest(expected, parts.get("v1", ""))Respond fast, then work
Return a 2xxas 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
How long do I have to verify a webhook?
The signature carries a timestamp; reject deliveries whose timestamp is more than five minutes (300 seconds) from now. That window is wide enough for clock skew and network delay but tight enough to stop replay of an old captured request.
What happens if my endpoint is down?
mails.ai attempts delivery inline first, then retries on a backoff schedule, up to three attempts total. After the final failure the delivery moves to a dead-letter state (dlq). You can inspect every attempt at GET /v1/webhooks/{id}/deliveries and replay any delivery once your endpoint recovers.