Your app sends a receipt. The address is dead. Postmark bounces it, marks the address inactive, and refuses the next send with error 406.
So Postmark already protects you. Why a webhook at all?
- Your app learns the address is dead. Support can tell the user to fix it.
- A pre-send check can refuse it without an API call.
- A spam complaint shows up somewhere a human will see it.
There is no signature to verify
Stripe, Resend and most modern providers sign each webhook with an HMAC. Postmark does not.
What Postmark offers:
- Basic auth. Set a username and password on the webhook (dashboard: Webhooks -> Basic auth credentials, or
https://user:pass@host/path). Postmark sendsAuthorization: Basic ...on every delivery. - Custom headers. Up to 30 static headers per webhook.
- Published IP ranges you can allowlist.
Basic auth over HTTPS is the one to build on. It is standard, every framework can read it, and rotation is two edits.
import { timingSafeEqual } from "node:crypto";
export function isAuthorized(header: string | null, user: string, pass: string): boolean {
const match = /^Basic\s+([A-Za-z0-9+/=]+)\s*$/i.exec(header ?? "");
if (!match?.[1]) return false;
const given = Buffer.from(match[1], "base64");
const wanted = Buffer.from(`${user}:${pass}`, "utf8");
return given.length === wanted.length && timingSafeEqual(given, wanted);
}
Rules that matter:
- Constant-time compare.
===leaks how many bytes matched. - HTTPS only. Basic auth is base64, not encryption.
- Refuse when unconfigured. No credentials set means answer 503. An open endpoint that writes to a suppression list lets anyone block your customers' mail.
- 503, not 500, for "not configured". It reads as temporary. Postmark retries.
- Not a query-string secret. URLs end up in logs.
IP allowlisting is a good second layer. It is a bad only layer: ranges change, and serverless platforms do not give you a stable view of the client IP without trusting a header.
Which events to act on
Postmark sends one JSON object per request. RecordType tells you which.
| RecordType | Act when | Do |
|---|---|---|
Bounce | Inactive: true | Suppress the address |
Bounce | Inactive: false | Log. Soft bounce, full mailbox, greylisting |
SpamComplaint | Always | Suppress. Tell a human if they spike |
SubscriptionChange | SuppressSending: true on your transactional stream | Suppress |
SubscriptionChange | SuppressSending: false | Remove from your list. Someone reactivated it in Postmark |
Delivery, Open, Click | Never for suppression | Acknowledge |
Use Inactive, not Type. Postmark has more bounce types than you want to map (HardBounce, BadEmailAddress, SpamNotification, ManuallyDeactivated...). Inactive is Postmark's own decision that the address is off.
Watch the MessageStream field on SubscriptionChange. Suppressions are per stream. An unsubscribe from the newsletter stream must not block the person's password reset.
Make redelivery harmless
Postmark retries any non-2xx response and any timeout. You will get the same event twice.
- Store suppressions keyed on the normalized address. Insert with "on conflict do nothing".
- Keep the first reason. A later complaint does not need to overwrite a hard bounce.
- Answer 200 fast. Do the database write, then return. No outbound calls in the handler.
- Answer 400 only for a body that is not JSON. A retry will not fix it.
Checklist
- [ ] Webhook created on the transactional stream, with Bounce, Spam Complaint and Subscription Change ticked.
- [ ] Basic-auth pair set in Postmark and in your env. Long random password.
- [ ] Route answers 503 with no credentials, 401 with wrong ones.
- [ ] Suppression write is an upsert.
- [ ] A test bounce to
hardbounce@bounce-testing.postmarkapp.comshows up on your list.