Email Bounce Handling with Webhooks: A Developer's Guide
Sending an email is one API call. Knowing what happened to it afterwards is where most integrations stop — and where deliverability problems start. An address that hard-bounced last month and still gets your weekly digest, or a user who reported you as spam and keeps receiving mail, is exactly what mailbox providers use to decide you are a careless sender.
This guide covers what bounce and complaint events mean, how to receive them safely with a webhook, and how to keep a suppression list that protects your reputation without dropping people you can still reach.
What a bounce actually is
A bounce is the receiving server saying no. Sometimes it refuses the message during the SMTP conversation; sometimes it accepts it and later sends a delivery status notification (DSN) back to the return-path. Either way you get a status code (RFC 3463) and a diagnostic line such as:
550 5.1.1 <user@example.com>: Recipient address rejected: User unknownThe first digit is what matters most: 5.x.x is permanent, 4.x.x is temporary. But the code alone is not enough to decide what to do with the address, which is why classification matters.
Hard, soft — and not the recipient’s fault
The usual split is hard vs. soft bounces, but for list hygiene a three-way split is more useful:
- The address is dead — mailbox does not exist, domain has no mail server. Suppress it immediately and permanently.
- The address is temporarily unavailable — mailbox full, server busy, greylisting. Let the sender retry; suppress only if it keeps failing across several separate messages.
- The message or sender was refused — spam block, policy rejection, authentication failure, message too large. The address is fine; the problem is on your side. Suppressing the recipient here hides the real issue and loses a reachable contact.
A permanent code does not always mean a dead address: a 5.7.1 spam block is permanent for that message, not for the mailbox.
Complaints and unsubscribes
A complaint arrives when a recipient marks your message as spam and their provider runs a feedback loop. Treat it as a permanent opt-out from anything that is not strictly transactional, and never re-add the address automatically. An unsubscribe — from a link or a one-click unsubscribe header — is the same instruction delivered politely.
Receiving events with a webhook
Your provider reports these outcomes as events. With PostStack, the ones that matter for list hygiene are:
email.bounced— a permanent failure, withbounce_code,bounce_messageand abounce_categorysuch asinvalid_mailbox,mailbox_fullorspam_block.email.soft_bounced— a temporary failure that will be retried.email.complained— the recipient reported the message as spam.email.unsubscribed— the recipient unsubscribed.email.suppressed— a send was blocked because the recipient is already on the suppression list.
A bounce event looks like this:
{
"type": "email.bounced",
"created_at": "2026-09-29T10:00:02.000Z",
"data": {
"email_id": "em_abc123def456ghi789",
"from": "you@yourdomain.com",
"to": ["user@example.com"],
"subject": "Your receipt",
"bounce_message": "550 5.1.1 <user@example.com>: Recipient address rejected: User unknown",
"bounce_code": "5.1.1",
"bounce_category": "invalid_mailbox"
}
}The to field lists the message’s recipients. Send transactional email to one recipient per message so every event maps to exactly one person.
Verify the signature, then respond fast
A webhook endpoint is a public URL, so check that each request really came from your provider before acting on it. PostStack signs the raw request body with HMAC-SHA256 and sends the result in the X-PostStack-Signature header as sha256=<hex>. Verify against the raw bytes — a parsed and re-serialised JSON body will not match:
import crypto from 'node:crypto';
import express from 'express';
const app = express();
function verify(rawBody: Buffer, header: string | undefined, secret: string): boolean {
if (!header) return false;
const expected = Buffer.from(
crypto.createHmac('sha256', secret).update(rawBody).digest('hex'),
);
// During a secret rotation the header carries more than one signature.
return header.split(',').some((part) => {
const [scheme, hex] = part.trim().split('=');
if (scheme !== 'sha256' || !hex) return false;
const given = Buffer.from(hex);
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}
app.post('/webhooks/email', express.raw({ type: 'application/json' }), async (req, res) => {
if (!verify(req.body, req.header('x-poststack-signature'), process.env.WEBHOOK_SECRET!)) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString('utf8'));
await queue.add('email-event', event); // process asynchronously
res.status(200).end();
});Respond with a 2xx as soon as the event is safely stored, and do the real work in a background job. PostStack retries non-2xx responses with exponential backoff over 8 attempts spread across more than a day, so a slow handler or a short outage does not lose events. If you deliberately don’t want an event, answer 406 and it will not be retried. The webhooks docs have the full event list and verification examples for other languages.
Process events idempotently
Retries mean the same event can arrive more than once, and events for one message can arrive out of order. Make every handler safe to run twice:
async function handleEmailEvent(event) {
const recipient = event.data.to[0];
switch (event.type) {
case 'email.bounced':
// Only suppress when the ADDRESS is bad, not when the message was refused.
if (['invalid_mailbox', 'dns_failure'].includes(event.data.bounce_category)) {
await db.contacts.markUndeliverable(recipient, event.data.bounce_code);
} else {
await alerts.notify('bounce not caused by the address', event.data);
}
break;
case 'email.complained':
case 'email.unsubscribed':
await db.contacts.optOut(recipient, event.type); // an upsert: safe to repeat
break;
case 'email.suppressed':
await db.contacts.markUndeliverable(recipient, 'suppressed');
break;
}
}Use upserts keyed on the address rather than inserts, and store the event’s created_at so an older event cannot overwrite a newer state.
Keep one suppression list, at the provider
The safest place for the suppression list is the provider, because it is checked on every send whatever code path triggered it — a new feature, a script someone ran by hand, an AI agent. Your own copy is for your UI and your analytics. PostStack keeps the list for you:
- A bounce that shows the address is dead suppresses it on the first failure. Bounces caused by the message or the sender — spam blocks, policy rejections — do not.
- A recipient whose bounces keep recurring — three separate messages within 30 days — is suppressed even if no single bounce was conclusive.
- Complaints and unsubscribes are suppressed and never lifted automatically.
- Bounce-based suppressions are rechecked after 180 days: if the recipient’s domain still has a mail server, the address gets one more chance, and a new hard bounce suppresses it again straight away.
You can list, add, import and remove entries through the suppressions API, for example to import an existing list when you switch providers.
Watch the rates, not just the events
Individual events keep the list clean; rates tell you when something is wrong. Alert when your bounce rate goes above 2% or your complaint rate above 0.1% over a day. A sudden jump usually means a bad import, a signup form being abused, or broken authentication — see why emails go to spam for how to tell which.
Frequently asked questions
Should I suppress every hard bounce?
Suppress bounces that show the address is dead, such as an unknown mailbox or a domain with no mail server. Do not suppress permanent rejections caused by the message or sender, such as spam blocks or policy rejections: the address is fine and the problem is on your side.
How do I verify a webhook signature?
Compute an HMAC-SHA256 of the raw request body with your webhook secret and compare it, in constant time, with the signature in the request header. Verify before parsing the JSON, because a re-serialised body will not match.
What if my webhook endpoint receives the same event twice?
Expect it. Providers retry deliveries that did not get a 2xx response, so handlers must be idempotent: use upserts keyed on the address, and store each event’s timestamp so an older event cannot overwrite a newer state.
Continue reading
A step-by-step guide to the three DNS records that authenticate your email: exact SPF, DKIM and DMARC examples, the mistakes that break them, and how to verify them with dig.
Why Are My Emails Going to Spam? A Developer's ChecklistDelivered is not the same as the inbox. A checklist of what sends email to spam — authentication, reputation, complaints, bounces, content and volume — and how to diagnose it.
Automate SPF, DKIM, and DMARC with HostStack DNSStop copy-pasting DNS records every time you add a sending domain. PostStack's HostStack integration publishes SPF, DKIM, DMARC, and return-path records straight into your zone and verifies in seconds.