Skip to content

Postmark bounce and spam complaint webhooks, secured without a signature

Postmark does not sign webhooks. Protect the endpoint with basic auth, act only on deactivating bounces, and make every write an upsert.

Postmark3 min readships at docs/solutions/postmark/bounces-complaints-and-webhook-auth.md

Tags: postmark · webhooks · bounces · spam-complaints · basic-auth · suppression

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 sends Authorization: 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.

RecordTypeAct whenDo
BounceInactive: trueSuppress the address
BounceInactive: falseLog. Soft bounce, full mailbox, greylisting
SpamComplaintAlwaysSuppress. Tell a human if they spike
SubscriptionChangeSuppressSending: true on your transactional streamSuppress
SubscriptionChangeSuppressSending: falseRemove from your list. Someone reactivated it in Postmark
Delivery, Open, ClickNever for suppressionAcknowledge

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.com shows up on your list.