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
- Failed deliveries retry up to 6 times (configurable via
NM_WEBHOOK_MAX_ATTEMPTS). - Exponential backoff doubling from a 30s base, capped at 1h.
- 2xx response = success. Anything else = retry.
- After max attempts the delivery is marked
FAILED; you can replay manually from the Webhooks page in your portal.
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.