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.
Create webhook endpoints in the dashboard (session-authenticated), each subscribed to any subset of the event types below. The endpoint secret — beginning whsec_ — is shown once at creation; use it to verify signatures.
Event types
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.opened | The recipient opened the message (tracking pixel loaded). |
| email.clicked | The recipient clicked a tracked link in the message. |
| email.received | Inbound mail arrived for one of your mailboxes or aliases. |
| domain.verified | All required 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 an
id,type,created_at, and a type-specificdataobject. - Respond
2xxpromptly — do slow work asynchronously. - Non-2xx responses are retried with exponential backoff; an endpoint that keeps failing is disabled and can be re-enabled from the dashboard.
- Delivery is at-least-once and unordered: dedupe on
id, order bycreated_at.
{ "id": "b0f8b1e2-6a3d-4c77-9f0e-1a2b3c4d5e6f", "type": "email.bounced", "created_at": "2026-08-28T09:12:44.000Z", "data": { "email_id": "6f6d2b1a-3c9e-4a51-8f2b-2d7c1a0e9b44", "from": "billing@yourco.com", "to": ["ava@client.io"], "subject": "Your March invoice", "smtp_code": "550", "smtp_response": "5.1.1 mailbox unavailable", "remote_mta": "mx.client.io" } }
The data shape depends on type. Email events (email.sent, delivered, deferred, bounced, complained) carry email_id, from, to, and — where applicable — subject, smtp_code, smtp_response, remote_mta. email.received carries message_id and mailbox_id; domain events carry domain_id and domain; mailbox events carry mailbox_id and address.
Verifying signatures
Every delivery carries a single atrix-signature header, in the Stripe-style format t=<unix seconds>,v1=<hex>. The v1 value is a hex HMAC-SHA256 over the string ${t}.${rawBody}, keyed with the endpoint secret. The timestamp is inside the signed string, so a captured request cannot be replayed later with a fresh-looking header.
| Header | Contents |
|---|---|
| atrix-signature | t=<seconds>,v1=<hex hmac> — HMAC-SHA256 of ${t}.${rawBody} |
The TypeScript SDK ships this verification. In one call, it checks the HMAC and the timestamp tolerance (default 300s) and returns the typed event:
import { AtrixMail, AtrixMailError, SIGNATURE_HEADER } from "@atrix-mail/sdk"; const mail = new AtrixMail(process.env.ATRIX_MAIL_KEY!); // Read the RAW body — do not JSON.parse before verifying. app.post("/hooks/atrix", (req, res) => { try { const event = mail.webhooks.constructEvent( req.rawBody, // exact bytes received, as a string req.header(SIGNATURE_HEADER), // "atrix-signature" process.env.ATRIX_WEBHOOK_SECRET!, // whsec_... ); if (event.type === "email.bounced") { console.log(event.data.email_id, event.data.smtp_code); } res.sendStatus(200); } catch (err) { if (err instanceof AtrixMailError && err.code === "invalid_signature") { return res.sendStatus(400); } throw err; } });
Or verify by hand with no dependencies:
import { createHmac, timingSafeEqual } from "node:crypto"; const TOLERANCE_S = 300; // reject deliveries signed >5 min ago export function verifyAtrixWebhook( rawBody: string, // exact bytes received — don't re-serialize header: string, // atrix-signature: "t=...,v1=..." secret: string, // whsec_... from the dashboard ): boolean { const parts = new Map( header.split(",").map((p) => { const i = p.indexOf("="); return [p.slice(0, i).trim(), p.slice(i + 1).trim()] as const; }), ); const t = parts.get("t"); const v1 = parts.get("v1"); if (!t || !v1 || !/^\d+$/.test(t)) return false; const age = Math.abs(Math.floor(Date.now() / 1000) - Number(t)); if (age > TOLERANCE_S) return false; const expected = createHmac("sha256", secret) .update(`${t}.${rawBody}`) .digest("hex"); const a = Buffer.from(expected, "utf8"); const b = Buffer.from(v1, "utf8"); return a.length === b.length && timingSafeEqual(a, b); }
Testing & local development
The dashboard's Send test button delivers a one-off webhook.test event to an endpoint (it is not a subscribable type). Failed real deliveries can be replayed from the endpoint's delivery log. Webhooks need a public URL, so for local work tunnel your dev server and register the tunnel:
ngrok http 3000 # then register https://<id>.ngrok.app/hooks/atrix as a webhook endpoint