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.

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.receivedInbound mail arrived for one of your mailboxes or aliases.
domain.verifiedAll 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 a type, id, created_at, and a type-specific data object.
  • Respond 2xx within 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 by created_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):

HeaderContents
atrix-signatureHex HMAC-SHA256 of ${timestamp}.${rawBody}
atrix-timestampUnix seconds when the delivery was signed
atrix-event-idThe 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