NextMail

Webhooks

Webhooks let your application react to email events the moment they happen. Same payload shape regardless of which provider produced the event — Resend, Postmark, SES, or raw SMTP.

Event types

DELIVEREDProvider confirmed acceptance by the recipient mail server
BOUNCEDHard bounce — address is invalid or the receiving server refused permanently
SOFT_BOUNCETransient failure (mailbox full, server down) — NextMail retries automatically
COMPLAINEDRecipient marked the email as spam
OPENEDRecipient opened the email (pixel-tracked)
CLICKEDRecipient clicked a link in the email
UNSUBSCRIBEDRecipient hit the unsubscribe link or sent a List-Unsubscribe one-click signal
FAILEDNextMail or the provider gave up trying to send

Payload shape

Every webhook POSTs the following JSON. X-NextMail-Signature holds an HMAC-SHA256 hex digest of the raw body, prefixed with sha256=.

application/json
{
  "id":         "evt_01HXBJ3K2…",
  "type":       "DELIVERED",
  "occurredAt": "2026-05-27T12:48:22.184Z",
  "tenantId":   "t_8gK9p2x1WqYz",
  "email": {
    "messageId":         "msg_…",
    "providerMessageId": "POSTMARK_abc123",
    "to":                "m.khan@example.com",
    "from":              "noreply@mail.headmanlaw.com",
    "subject":           "NIW eligibility result"
  },
  "provider": "POSTMARK",
  "raw":      { /* original provider payload, for debugging */ }
}

Verifying the signature

Always verify before trusting the body. The secret is the one shown when you created the webhook — it's never sent again, so store it.

verify.ts
import crypto from 'node:crypto';

function verifyNextMailSignature(
  rawBody: string,
  signatureHeader: string,
  secret: string,
): boolean {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');

  const sent = signatureHeader.startsWith('sha256=')
    ? signatureHeader.slice(7)
    : signatureHeader;

  // Constant-time compare — never use ===
  return crypto.timingSafeEqual(
    Buffer.from(expected, 'hex'),
    Buffer.from(sent, 'hex'),
  );
}

Retry semantics

Idempotency

Each event has a unique id. Treat it as the idempotency key on your side — store processed ids and skip duplicates. Retries can and will replay events you've already handled.