Webhooks

Webhooks let your application react to email events the moment they happen. Same payload shape whether the event came from self-hosted direct-to-MX or a provider adapter (Resend / Postmark / SES).

Event types

DELIVEREDRecipient mail server accepted the message
BOUNCEDHard bounce — address is invalid or the receiving server refused permanently
SOFT_BOUNCETransient failure (mailbox full, greylisting, server down) — retried automatically with exponential backoff
COMPLAINEDRecipient marked the email as spam
UNSUBSCRIBEDRecipient hit the unsubscribe link or sent a List-Unsubscribe one-click signal
FAILEDDelivery gave up after exhausting retries

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": null,
    "to":                "recipient@example.com",
    "from":              "noreply@your-domain.com",
    "subject":           "Your order has shipped"
  },
  "provider": "SMTP",
  "raw":      { /* original provider/SMTP transcript, 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 verifyKovarroMailSignature(
  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.