Skip to content

Bounces and spam complaints: listen, or lose the inbox for everyone

A hard bounce means the mailbox is gone. Keep sending and mailbox providers downgrade every message from your domain. Wire the webhook, suppress permanently, and never suppress on a soft bounce.

Resend6 min readships at docs/solutions/resend/bounces-complaints-and-webhooks.md

Tags: resend · bounces · complaints · webhooks · suppression · deliverability

Every message you send is a vote on your sender reputation. A message accepted by a real mailbox is a small positive vote. A message to an address that does not exist is a large negative one. A spam complaint is larger still.

Providers do not judge the individual message: they judge the domain. Which means a few hundred sends to dead addresses can push every email from your domain into the spam folder, including the sign-in links your paying customers are waiting for.

The fix is not clever. It is: listen to what the provider tells you, and stop sending to addresses that failed.

Hard versus soft, and why the distinction matters

Hard bounce: permanent. The mailbox does not exist, the domain does not resolve, the server refuses you outright. SMTP codes in the 5xx range:

550 5.1.1 The email account that you tried to reach does not exist
550 5.4.1 Recipient address rejected: Access denied

Retrying achieves nothing except more negative votes. Suppress the address.

Soft bounce: temporary. Mailbox full, server down, greylisting, a rate limit at the receiving end. 4xx codes:

452 4.2.2 The email account that you tried to reach is over quota
421 4.7.0 Try again later

These usually deliver on a later attempt. Suppressing on a soft bounce throws away a real customer who is reachable tomorrow, and it is the mistake people make when they wire the webhook without reading the bounce type.

The route in this repo checks it:

const permanent = event.data.bounce?.type?.toLowerCase() === "permanent";
if (permanent) {
  for (const address of to) await suppress(address, "hard_bounce");
}

Complaint: someone pressed "report spam". Always permanent, always suppress, regardless of what consent you believe you had. Arguing with a complaint is not a strategy; the provider has already counted it.

Keep the complaint rate under 0.1%. Google's bulk-sender guidance treats 0.3% as the point where enforcement starts, and by then you are already being filtered.

Wiring the webhook

In Resend: Webhooks → Add endpoint, pointing at https://<your-domain>/api/webhooks/resend, subscribed to email.bounced, email.complained and email.delivered. Copy the signing secret into RESEND_WEBHOOK_SECRET.

Resend signs with Svix (the standard-webhooks scheme): svix-id, svix-timestamp and svix-signature over the raw body. Two rules, the same as every signed webhook:

  • Verify the raw bytes with await request.text(). await request.json() re-serialises and every signature fails.
  • Pass all three headers. The timestamp is inside the signed string and is checked for freshness, which is what stops a captured payload being replayed.

Return 401 on a verification failure and 200 once you have handled it. When RESEND_WEBHOOK_SECRET is missing the route answers 503, not 500: a sustained run of 500s is what makes a provider disable an endpoint outright, and "we forgot a variable" should not turn into "bounces stopped arriving and nobody noticed". bun run verify says so in the email check rather than failing, because local development does not need the webhook.

There is deliberately no delivery-id ledger. Svix rejects a payload whose timestamp is outside its tolerance, which closes the replay window, and the only side effect is suppress(): an upsert on the address, so a redelivery of the same event costs one query and changes nothing.

The suppression list

sendEmail checks it before every send and throws EmailSuppressedError rather than mailing a known-dead address. That check is the entire point of the webhook: events you record and never read are just logs.

Where the list is kept is decided by the ORM this repo selected, and the answer is already written: src/lib/email/store.ts.

  • Drizzle or Prisma. The email_suppressions table (address as the primary key, reason, created_at) declared in src/db/email-schema.ts or in prisma/schema.prisma, with identical SQL either way. Run a migration and the webhook works across instances. The insert is an upsert, so a redelivered event is free and the first reason recorded wins.
  • No ORM. An in-process Map. Genuinely fine in development and on a single long-lived server, genuinely useless on serverless, where every instance starts empty and the webhook's write lands somewhere the next send never reaches.

That last case is the launch blocker, and the fix is a battery rather than a patch: add Drizzle or Prisma and the file is regenerated with no change to any caller, because suppression.ts only ever sees the SuppressionStore interface.

On an ORM build the webhook route reaches @/db, which constructs its client at module scope, so DATABASE_URL must be set wherever bun run build runs, since Next.js evaluates every route module while collecting page data. The auth and billing routes already require this, so it is a property of having a database rather than of having email.

If the list already lives somewhere else (another service, a table you share across apps) implement that interface and install it once at startup:

import { setSuppressionStore } from "@/lib/email/suppression";

setSuppressionStore({
  async has(address) { /* ... */ },
  async add(address, reason) { /* ... */ },
  async remove(address) { /* ... */ },
});

Addresses arrive normalised, so a store never has to lowercase again.

Removing an address

Only on an explicit request from the person who owns the mailbox. "I fixed my email, please try again" is a valid reason. "Our list shrank, let us re-add everyone" is how a sending domain dies: those addresses bounced for a reason, and the second run produces the same bounces plus a reputation penalty.

Never bulk-clear the suppression list. If you find yourself wanting to, the underlying problem is list quality, not the list.

Preventing bounces in the first place

  • Validate at signup. Syntax, then an MX lookup on the domain. It catches gmial.com before it ever becomes a bounce.
  • Use double opt-in for anything non-transactional. It is the single biggest lever on both bounce rate and complaint rate.
  • Never buy or scrape a list. Beyond being illegal in much of the world, purchased lists are full of spam traps: addresses that exist only to catch senders like that, and hitting one can block your domain outright.
  • Re-engage or drop the inactive. An address that has not opened anything in a year may have been recycled into a spam trap.
  • Watch the dashboard weekly. Bounce rate above 2% or complaint rate above 0.1% is a problem to fix now, not a metric to note.

When it has already gone wrong

Stop sending bulk mail. Fix the list: suppress every hard bounce you have recorded, remove everyone who has not engaged. Then rebuild volume slowly from a low base over a couple of weeks. Reputation recovers, but on the provider's timescale, not yours, and only if you stop doing the thing that caused it.