Skip to content
Back to Blog
guide
deliverability
webhooks

Email Bounce Handling with Webhooks: A Developer's Guide

By Michael Andersen4 min readFact-checked September 29, 2026

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 unknown

The 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, with bounce_code, bounce_message and a bounce_category such as invalid_mailbox, mailbox_full or spam_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.

Michael Andersen

Michael Andersen

Founder, PostStack

Building PostStack — a European email API hosted in Helsinki, Finland. Background in self-hosting Postfix, deliverability operations, and high-throughput backend systems.

Continue reading

Ready to get started?

Send your first email in under 5 minutes. Free plan included.