Build a Bounce & Complaint Handler That Protects Reputation
Ignoring bounces and complaints is how good senders quietly ruin their reputation. How to build a handler that suppresses the right addresses and keeps your complaint rate low.
Bounces and complaints feel like someone else's problem — the recipient's address is dead, or they didn't want your mail. But mailbox providers read them as signals about you. Keep mailing addresses that hard-bounce, or keep provoking spam complaints, and Gmail and Yahoo conclude you're not maintaining your list — and start routing even your good mail to spam. The handler that suppresses the right addresses at the right time is one of the highest-leverage pieces of infrastructure a sender can build, and it's mostly a few branches on a webhook.
Every hard bounce you re-send to is a vote, cast to Gmail, that you don't clean your list. Enough votes and your good mail pays for it.
Know your event types before you branch on them#
Not all failures mean the same thing, and treating them identically is the classic mistake.
- Hard bounce (
email.bounced, permanent — e.g.550 5.1.1unknown recipient). The address doesn't exist or won't ever accept your mail. Suppress permanently. Re-sending achieves nothing and damages reputation. - Soft bounce (temporary — e.g.
421 4.7.0, mailbox full, greylisting). A transient condition. Retry a few times with backoff, and only suppress if it keeps failing over a reasonable window. - Complaint (
email.complained). The recipient clicked "spam." This is the most damaging signal of all. Suppress immediately and permanently — never mail a complainer again — and treat a rising complaint rate as an emergency. - Failed (
email.failed). The platform couldn't deliver for another recorded reason. Read the reason; suppress if it's permanent.
The reason string matters. Don't hard-suppress on a temporary code, and don't keep retrying a permanent one. The provider tells you which is which in the SMTP reply; your handler's job is to route on it.
Why the thresholds are non-negotiable#
This isn't etiquette; it's enforced. Gmail's sender guidance is explicit that senders should keep the spam rate reported in Postmaster Tools below 0.3%, and treats sustained breaches as grounds for throttling, spam-foldering, or rejection. Yahoo and Microsoft align on the same direction. A complaint rate that drifts toward that line is a fire, because by the time placement visibly drops, the reputation damage is already done and slow to repair. (More on the provider rules in Gmail & Yahoo bulk-sender rules.)
The corollary: your suppression list is a reputation asset. It's the record of every address you must not mail. Honour it on every send — check it before you call the send endpoint, not after.
The handler design#
Two halves, and both are essential:
1. Ingest events → maintain the suppression list.
- Verify the webhook signature (see verifying signed webhooks), ACK fast, process async.
- Hard bounce / complaint → add to suppression with the reason and timestamp.
- Soft bounce → increment a counter; suppress only after N failures over a window.
- Record everything for auditing and to explain "why did this user stop getting mail?"
2. Enforce the suppression list → gate every send.
- Before any
POST /v1/send, check the address against your suppression store and skip if present. - Notifiva also maintains suppressions on its side (there's a Suppressions API group); use it as the source of truth and mirror it locally for a fast pre-send check.
- Provide a way to remove a suppression deliberately (e.g. a user fixes their mailbox and re-verifies) — but never automatically un-suppress a complaint.
Hands-on: route events and gate sends#
Handler (signature-verified upstream; this is the routing logic):
// Node worker — route delivery events to suppression decisions
const SOFT_BOUNCE_LIMIT = 4;
async function handleEvent({ type, data }) {
const addr = data.to;
switch (type) {
case "email.complained": // most damaging — never mail again
await suppress(addr, { reason: "complaint", permanent: true });
await alertIfComplaintRateRising();
break;
case "email.bounced": // check permanence from the reason/code
case "email.failed":
if (isPermanent(data.reason)) { // e.g. 5.x.x codes
await suppress(addr, { reason: data.reason, permanent: true });
} else {
const n = await bumpSoftBounce(addr); // transient — retry, then suppress
if (n >= SOFT_BOUNCE_LIMIT) {
await suppress(addr, { reason: "repeated soft bounce", permanent: false });
}
}
break;
case "email.delivered":
await clearSoftBounce(addr); // recovered — reset the counter
break;
}
}Gate every send with a pre-check:
# Python — never send to a suppressed address
def send_email(addr, payload):
if suppression.contains(addr): # local mirror of the suppression list
log.info("skip suppressed", addr=addr)
return {"skipped": "suppressed"}
return notifiva.send(to=addr, **payload) # only reachable addresses get hereThat pre-check is the single most important line for reputation: it's the difference between "we suppressed the bounce" and "we suppressed the bounce and then mailed it again anyway."
Don't forget the human questions#
A good handler also answers the support questions that follow:
- "Why did this user stop getting our email?" — because you logged the suppression with a reason and timestamp, you can answer in seconds instead of guessing.
- "Can we re-enable them?" — for soft-bounce and address-fix cases, yes, via a deliberate un-suppress. For complaints, no.
- "Is our complaint rate trending up?" — alert on it. A rising rate is the earliest warning you'll get before placement drops.
Example scenario: the receipts that kept bouncing#
(Illustrative scenario, not a customer case.) An app sends order receipts and never wired up bounce handling — receipts "always send," so why check? Over a year, a few percent of addresses go dead (people change jobs, close accounts). The app keeps mailing them monthly. The hard-bounce rate climbs past what Gmail tolerates, and one quarter the good receipts start landing in spam, generating support tickets from customers who "never got their receipt." The dead addresses were the cause; the fix — suppress on email.bounced, pre-check before send — took an afternoon and reversed the placement slide within weeks as the bounce rate fell.
Editorial disclosure: Drafted with AI assistance; reviewed, fact-checked and edited by Alkım Kaplaner. Next scheduled review: .
Frequently asked questions
What's the difference between a hard and soft bounce?
A hard bounce is permanent — the address doesn't exist or won't accept your mail (typically a 5.x.x code) — so you suppress it immediately. A soft bounce is temporary — mailbox full, greylisting, a transient 4.x.x code — so you retry with backoff and only suppress if it keeps failing over a reasonable window.
How low does my complaint rate need to be?
Gmail's guidance is to keep the spam rate reported in Postmaster Tools below 0.3%, and to avoid ever approaching it. Complaints are the most damaging deliverability signal, so suppress complainers immediately and alert on any upward trend before it affects placement.
Should I ever email an address that complained?
No. Treat a complaint as permanent suppression — never mail that address again, and don't auto-un-suppress it. Continuing to send to complainers is the fastest way to convince a mailbox provider to route all your mail to spam.
Where should the suppression list live?
Notifiva maintains suppressions (there's a Suppressions API group) as a source of truth; mirror it locally so you can do a fast pre-send check without a network round-trip. The critical rule is to check it before calling send, not to clean up afterward.
Do transactional senders really need bounce handling?
Yes — arguably more than bulk senders, because transactional mail "always sends" and teams assume it's fine. Addresses still go dead over time, and re-mailing them raises your bounce rate and drags down placement for the receipts, resets and codes that must arrive.
Send transactional email you can rely on
Notifiva gives you a REST API and an SMTP relay, SPF/DKIM signing, delivery webhooks and per-message logs — so the mail your users are waiting on actually arrives.