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
- NextMail retries failed deliveries up to 8 times
- Backoff: 30s, 2m, 10m, 30m, 1h, 4h, 12h, 24h
- 2xx response = success. Anything else = retry.
- After 8 failures the delivery is marked
FAILED; you can replay manually from the Webhooks page. - Endpoints with three consecutive failures get a warning email; ten in a row pauses the webhook automatically.
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.