Bounces, complaints, and suppression lists: what to do with them
Almost every deliverability problem that is not a DNS problem comes from ignoring feedback. The mailbox providers tell you which addresses are dead and which recipients marked you as spam, and a system that keeps sending anyway is telling them it does not care. Handling the feedback is a day of work and it is the cheapest reputation protection available.
5 min read
Three signals, three responses
| Signal | What happened | Response |
|---|---|---|
| Hard bounce | The address does not exist, or the domain does not accept mail | Suppress permanently, immediately |
| Soft bounce | Mailbox full, server temporarily unavailable, greylisted | Let the provider retry. Suppress only after repeated failures |
| Complaint | The recipient pressed the spam button | Suppress permanently, for marketing and optional mail |
| Unsubscribe | They opted out of marketing | Suppress marketing only, never transactional |
| Blocked | The receiver rejected on reputation or content | Do not suppress the address. Fix the cause |
The rows people conflate are the last two. An unsubscribe is a preference about one stream; a hard bounce is a fact about the address. A block is about you, not about them, and suppressing the recipient hides the problem instead of solving it.
Hard bounces
A hard bounce is permanent. The address is gone, the domain does not resolve, or the server said no in terms that will not change. Retrying is not merely useless, it is actively harmful: the proportion of your mail hitting non-existent addresses is one of the strongest quality signals mailbox providers use, and some providers operate spam traps on recycled addresses precisely to catch senders who never clean their lists.
Suppress on the first hard bounce. There is no second opinion to wait for.
Soft bounces
A soft bounce is temporary: a full mailbox, a server under load, greylisting, a rate limit. Your provider retries these for you over a period of hours, so your application should usually do nothing at all.
What your application should do is count them. An address that soft bounces on every send for a week is effectively dead, whatever the response code claims. A reasonable rule is to suppress after several consecutive soft bounces with no delivery in between, and to reset the counter on any successful delivery.
Complaints
A complaint means someone pressed the spam button on your message. It arrives through a feedback loop and it is the most serious signal you receive, because complaint rate is what triggers filtering and account reviews. Mailbox providers publish thresholds in the region of a tenth of a percent, and exceeding them affects everything you send, not only the campaign that caused it.
Suppress a complainer from marketing mail immediately and permanently. Never argue with it and never re-add them because they are still a customer.
The one judgement call is transactional mail. Someone who marks a receipt as spam has still bought something, and withholding proof of payment causes its own problems. The usual answer: keep sending genuinely essential mail such as receipts, security alerts, and password resets, and stop everything else. If complaints on receipts are common, the receipts are carrying promotional content and should not be, per transactional email versus marketing email.
Wire the webhook
You cannot act on feedback you do not receive. Subscribe to the delivery events and handle them in one place.
import { createHmac, timingSafeEqual } from 'node:crypto';
export const runtime = 'nodejs';
export async function POST(request: Request) {
const raw = await request.text();
const signature = request.headers.get('x-emailssh-signature') ?? '';
const expected = createHmac('sha256', process.env.EMAILSSH_WEBHOOK_SECRET!)
.update(raw)
.digest('hex');
const a = Buffer.from(signature);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return new Response('bad signature', { status: 401 });
}
const event = JSON.parse(raw) as {
type: 'email.delivered' | 'email.bounced' | 'email.complained';
data: { id: string; to: string; bounce_type?: 'hard' | 'soft'; reason?: string };
};
switch (event.type) {
case 'email.delivered':
await db.recipients.recordDelivery(event.data.to);
break;
case 'email.bounced':
if (event.data.bounce_type === 'hard') {
await db.suppressions.add(event.data.to, 'hard_bounce', event.data.reason);
} else {
await db.recipients.countSoftBounce(event.data.to);
}
break;
case 'email.complained':
await db.suppressions.add(event.data.to, 'complaint');
await db.marketing.optOut(event.data.to);
break;
}
return new Response('ok', { status: 200 });
}Verify the signature over the raw body, before parsing. Any framework that hands you a parsed object has already re-serialised it, and the bytes will no longer match. Return 200 quickly and do slow work elsewhere, because a slow handler gets retried.
Check before you send
A suppression list you write to and never read is a log file. The check belongs inside your single send function, where it cannot be skipped.
type Stream = 'transactional' | 'marketing';
export async function send(stream: Stream, to: string, message: Message) {
const suppression = await db.suppressions.find(to);
if (suppression?.reason === 'hard_bounce') return { skipped: 'hard_bounce' };
if (stream === 'marketing' && suppression) return { skipped: suppression.reason };
return post(to, message, stream);
}A hard bounce blocks everything, because the address does not exist and no stream can reach it. A complaint or unsubscribe blocks marketing and lets essential transactional mail through.
Numbers to watch
Track these as rates, weekly, per stream. Absolute counts tell you nothing.
| Metric | Where you want to be | What it means if it rises |
|---|---|---|
| Hard bounce rate | Well under one percent | List quality is bad, or signup lacks verification |
| Soft bounce rate | Low and stable | A spike usually means one receiver is blocking you |
| Complaint rate | Under a tenth of a percent | Expectations are mismatched, or consent is unclear |
| Delivery rate | High and flat | A drop is a reputation event, investigate the same day |
A hard bounce rate above a few percent usually means signup accepts anything typed into a form. Verifying addresses at signup fixes it upstream: building an email verification flow that actually works.
Exporting and importing
When you change providers, the suppression list moves with you. Sending to an address that hard bounced elsewhere will hard bounce again, from a new sending setup that has no reputation to absorb it. Export before you migrate and import before your first real send. The migration sequence is in Resend alternatives, compared honestly and the SendGrid free tier changed.
If you would rather talk through a large migration with someone, contact us.
Questions
- Should I suppress an address after one bounce?
- After one hard bounce, yes. After one soft bounce, no: those are temporary and your provider retries them. Count consecutive soft bounces and suppress only when an address has repeatedly failed with no delivery in between.
- Can I keep sending password resets to someone who marked me as spam?
- Yes, for genuinely essential mail: password resets, security alerts, and receipts. Stop everything optional. If people are complaining about your transactional mail, it is probably carrying promotional content it should not.
- What is an acceptable complaint rate?
- Major mailbox providers publish thresholds around a tenth of a percent for bulk senders, with filtering starting well before that. Treat any sustained rise as an emergency, because it affects every stream you send from that domain.
- Do I need a suppression list if my provider has one?
- Yes. Your provider stops the send, but your application still believes it sent something, and your product logic still shows the user as contactable. Keeping your own list lets you show accurate state and stop generating the work in the first place.
Give your agent an address it can answer from.
Create an inbox