# 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.

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.

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

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
