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.

EventWhen it fires
email.sentWe handed the message to the recipient's server (SMTP accepted).
email.deliveredThe recipient's server confirmed final delivery.
email.deferredTemporarily refused (4xx); we keep retrying with backoff.
email.bouncedPermanently refused (5xx); the recipient is suppressed.
email.complainedThe recipient marked the message as spam via a feedback loop.
email.openedThe recipient opened the message (tracking pixel loaded).
email.clickedThe recipient clicked a tracked link in the message.
email.receivedInbound mail arrived for one of your mailboxes or aliases.
domain.verifiedAll required DNS records for a domain passed verification.
domain.failedA previously verified DNS record drifted or disappeared.
mailbox.createdA mailbox was provisioned on one of your domains.
mailbox.deletedA 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-specific data object.
  • Respond 2xx promptly — 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 by created_at.
json
{
  "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.

HeaderContents
atrix-signaturet=<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:

ts
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:

ts
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);
}
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.

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:

shell
ngrok http 3000
# then register https://<id>.ngrok.app/hooks/atrix as a webhook endpoint