Webhooks
Register an HTTPS endpoint and we POST you a signed JSON event for everything that happens to your mail — deliveries, bounces, inbound messages, domain health.
Event types
Subscribe per endpoint to any subset of the event types below. The list is the platform contract from @atrix-mail/shared — this table renders it directly.
| Event | When it fires |
|---|---|
| email.sent | We handed the message to the recipient's server (SMTP accepted). |
| email.delivered | The recipient's server confirmed final delivery. |
| email.deferred | Temporarily refused (4xx); we keep retrying with backoff. |
| email.bounced | Permanently refused (5xx); the recipient is suppressed. |
| email.complained | The recipient marked the message as spam via a feedback loop. |
| email.received | Inbound mail arrived for one of your mailboxes or aliases. |
| domain.verified | All DNS records for a domain passed verification. |
| domain.failed | A previously verified DNS record drifted or disappeared. |
| mailbox.created | A mailbox was provisioned on one of your domains. |
| mailbox.deleted | A mailbox was deleted; its address may be re-created later. |
Delivery contract
- Events are POSTed as JSON with a
type,id,created_at, and a type-specificdataobject. - Respond
2xxwithin 10 seconds — do slow work async. - Non-2xx responses are retried with exponential backoff for up to 24 hours.
- Delivery is at-least-once and unordered: dedupe on
id, order bycreated_at.
json
{ "id": "evt_5c1d90aa", "type": "email.bounced", "created_at": "2026-08-28T09:12:44Z", "data": { "email_id": "em_9f2c4a7b", "to": "ava@client.io", "smtp_code": 550, "reason": "5.1.1 mailbox unavailable" } }
Verifying signatures
Every delivery carries an HMAC-SHA256 signature over timestamp.body, using the endpoint's secret (shown when you create the webhook):
| Header | Contents |
|---|---|
| atrix-signature | Hex HMAC-SHA256 of ${timestamp}.${rawBody} |
| atrix-timestamp | Unix seconds when the delivery was signed |
| atrix-event-id | The event id — use it to dedupe retries |
ts
import { createHmac, timingSafeEqual } from "node:crypto"; const TOLERANCE_S = 300; // reject deliveries signed >5 min ago export function verifyWebhook( rawBody: string, // the exact bytes received — don't re-serialize signature: string, // atrix-signature header timestamp: string, // atrix-timestamp header secret: string, // whsec_... from the dashboard ): boolean { const age = Math.abs(Date.now() / 1000 - Number(timestamp)); if (!Number.isFinite(age) || age > TOLERANCE_S) return false; const expected = createHmac("sha256", secret) .update(`${timestamp}.${rawBody}`) .digest("hex"); const a = Buffer.from(expected, "hex"); const b = Buffer.from(signature, "hex"); return a.length === b.length && timingSafeEqual(a, b); }
Compute the HMAC over the raw request body, before any JSON parsing — parsing and re-stringifying reorders keys and breaks the signature. In most frameworks that means reading the body as text or a buffer in the webhook route.
Local development
Webhooks need a public URL. For local work, tunnel your dev server and register the tunnel URL as a test endpoint:
shell
ngrok http 3000 # then register https://<id>.ngrok.app/hooks/mail as a webhook endpoint