Blog/How to handle email bounces programmatically
·9 min read·
Paulo CastellanoPaulo Castellano

How to handle email bounces programmatically

Build proper bounce handling with webhooks, suppression lists, and retry logic to protect your sender reputation.

deliverabilitywebhooksbest-practicestutorial
How to handle email bounces programmatically

Every email you send to a dead address is a signal to mailbox providers that you do not know what you are doing. Enough of those signals and your emails stop reaching real people. Bounce handling is not a nice-to-have. It is infrastructure that protects your ability to send email at all.

This guide walks through building programmatic bounce handling with Sendkit's email API: webhook endpoints, suppression lists, retry logic, and complaint processing. By the end you will have a system that keeps your bounce rate well under the thresholds that matter.

What are email bounces

A bounce happens when an email cannot be delivered to the recipient. The receiving mail server sends back a rejection, and your email provider captures it. There are two types.

Hard bounces are permanent failures. The email address does not exist, the domain is invalid, or the recipient server has explicitly blocked delivery. Common SMTP codes: 550 (mailbox not found), 551 (user not local), 552 (mailbox full, sometimes classified as hard). Once you get a hard bounce, that address is dead to you. Do not send to it again.

Soft bounces are temporary failures. The mailbox is full, the server is temporarily down, a rate limit was hit, or there is a DNS issue on the receiving end. SMTP codes in the 4xx range. These might resolve on their own, so retrying makes sense, but only up to a point.

The distinction matters because your handling logic should be completely different for each type.

Why bounces damage sender reputation

Mailbox providers track your bounce rate as a core signal for sender reputation. Google and Yahoo enforced a hard ceiling in 2024: if your bounce rate exceeds 2%, you start getting throttled or blocked. Microsoft followed with similar enforcement across Outlook.com and Hotmail in 2025.

The math is simple. If you send 10,000 emails and 300 bounce, you are at 3%. That is enough for Gmail to start routing your mail to spam for everyone on your list, including the valid addresses. Your deliverability craters, open rates drop, and you are stuck in a hole that takes weeks to dig out of.

The fix is not complicated: stop sending to addresses that bounce, and validate addresses before they enter your list. But you need the plumbing to make that happen automatically.

For a deeper look at all the factors that affect inbox placement, read our guide to improving email deliverability.

Setting up a webhook endpoint for bounce events

Sendkit sends webhook events in real time when something happens to an email you sent. The events relevant to bounce handling are:

  • email.bounced — the email bounced (hard or soft)
  • email.complained — the recipient marked your email as spam
  • email.delivered — successful delivery confirmation
  • email.failed — the email failed to send entirely

You configure webhook endpoints in the Sendkit dashboard or via the API. Sendkit signs every webhook payload with HMAC-SHA256, sent in the x-sendkit-signature header, so you can verify the request is legitimate.

Monitoring dashboard showing email delivery metrics

Building the webhook handler

Here is a complete Express.js webhook handler that verifies the signature and routes events to the right processing logic.

import express from 'express';
import crypto from 'crypto';
import { Sendkit } from '@sendkitdev/sdk';

const app = express();
const sendkit = new Sendkit('sk_your_api_key');
const WEBHOOK_SECRET = process.env.SENDKIT_WEBHOOK_SECRET;

app.post('/webhooks/sendkit', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-sendkit-signature'] as string;
  const expectedSignature = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(req.body)
    .digest('hex');

  if (signature !== expectedSignature) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  const event = JSON.parse(req.body.toString());

  switch (event.type) {
    case 'email.bounced':
      handleBounce(event.data);
      break;
    case 'email.complained':
      handleComplaint(event.data);
      break;
    case 'email.failed':
      handleFailure(event.data);
      break;
  }

  res.status(200).json({ received: true });
});

Always verify the signature before processing any webhook payload. Without verification, anyone can send fake bounce events to your endpoint and corrupt your suppression list.

Processing hard bounces

Hard bounces are non-negotiable. The address is dead. Suppress it immediately and never send to it again.

const handleBounce = async (data: {
  recipient: string;
  bounce_type: string;
  error_code: string;
  message: string;
}) => {
  if (data.bounce_type === 'hard') {
    await db.suppressionList.upsert({
      where: { email: data.recipient },
      create: {
        email: data.recipient,
        reason: 'hard_bounce',
        errorCode: data.error_code,
        suppressedAt: new Date(),
      },
      update: {
        reason: 'hard_bounce',
        errorCode: data.error_code,
        suppressedAt: new Date(),
      },
    });

    await db.contact.update({
      where: { email: data.recipient },
      data: { status: 'suppressed' },
    });

    console.log(`Hard bounce: suppressed ${data.recipient}`);
    return;
  }

  if (data.bounce_type === 'soft') {
    await handleSoftBounce(data);
  }
};

No retry. No second chances. A hard bounce means the mailbox does not exist. Retrying just increases your bounce rate and damages your reputation further.

Processing soft bounces with exponential backoff

Soft bounces deserve retries, but with limits. Retry a few times with increasing delays. If the address keeps failing, treat it as a hard bounce.

const MAX_SOFT_BOUNCES = 3;
const BACKOFF_DELAYS = [60_000, 300_000, 3_600_000]; // 1min, 5min, 1hr

const handleSoftBounce = async (data: { recipient: string; error_code: string }) => {
  const record = await db.softBounce.findUnique({
    where: { email: data.recipient },
  });

  const bounceCount = (record?.count ?? 0) + 1;

  if (bounceCount >= MAX_SOFT_BOUNCES) {
    await db.suppressionList.create({
      data: {
        email: data.recipient,
        reason: 'repeated_soft_bounce',
        errorCode: data.error_code,
        suppressedAt: new Date(),
      },
    });

    console.log(`Soft bounce limit reached: suppressed ${data.recipient}`);
    return;
  }

  await db.softBounce.upsert({
    where: { email: data.recipient },
    create: { email: data.recipient, count: 1, lastBouncedAt: new Date() },
    update: { count: bounceCount, lastBouncedAt: new Date() },
  });

  const delay = BACKOFF_DELAYS[bounceCount - 1];

  setTimeout(async () => {
    await sendkit.emails.send({
      from: '[email protected]',
      to: data.recipient,
      subject: 'Your pending notification',
      html: '<p>Retry attempt</p>',
    });
  }, delay);
};

Three strikes is a reasonable threshold. After three soft bounces, the address is either permanently broken or consistently unreliable. Either way, you do not want it on your list.

Server infrastructure handling webhook events

Building a suppression list

Your suppression list is a database table that you check before every single send. No exceptions. The schema is straightforward:

CREATE TABLE suppression_list (
  id SERIAL PRIMARY KEY,
  email VARCHAR(255) UNIQUE NOT NULL,
  reason VARCHAR(50) NOT NULL, -- hard_bounce, repeated_soft_bounce, complaint
  error_code VARCHAR(20),
  suppressed_at TIMESTAMP DEFAULT NOW(),
  created_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_suppression_email ON suppression_list (email);

Before every send, check the list:

const sendEmail = async (to: string, subject: string, html: string) => {
  const suppressed = await db.suppressionList.findUnique({
    where: { email: to },
  });

  if (suppressed) {
    console.log(`Skipping suppressed address: ${to} (${suppressed.reason})`);
    return { skipped: true, reason: suppressed.reason };
  }

  return sendkit.emails.send({
    from: '[email protected]',
    to,
    subject,
    html,
  });
};

For bulk sends, query the suppression list in batch rather than one at a time. Pull all suppressed addresses for your recipient list in a single query and filter them out before you start sending.

If you are managing contacts through Sendkit, you can sync suppression status to keep everything consistent across your sending infrastructure.

Sendkit's automatic suppression

Here is the good news: Sendkit maintains its own suppression list automatically. When a hard bounce comes back, Sendkit suppresses that address across your account. You do not need to build any of the above to get basic protection.

So why build your own? Three reasons.

Custom logic. You might want different thresholds for different email types. Transactional emails (password resets, order confirmations) might warrant more aggressive retries than marketing emails. Your own suppression layer lets you make those decisions.

Cross-system sync. If you send from multiple services or have addresses in a CRM, your suppression data needs to propagate everywhere. Your own database is the source of truth.

Audit trail. When someone asks why they stopped receiving emails, you can show exactly when the suppression happened, what caused it, and which bounce event triggered it.

Think of Sendkit's automatic suppression as the safety net and your custom logic as the fine-tuned layer on top.

Monitoring bounce rates

You need visibility into your bounce rate on every send. Set up a simple monitoring function:

const trackBounceRate = async (campaignId: string) => {
  const stats = await db.emailEvent.groupBy({
    by: ['event_type'],
    where: { campaignId },
    _count: true,
  });

  const total = stats.reduce((sum, s) => sum + s._count, 0);
  const bounces = stats.find(s => s.event_type === 'email.bounced')?._count ?? 0;
  const bounceRate = (bounces / total) * 100;

  if (bounceRate > 1.5) {
    // Alert before hitting the 2% threshold
    await alertOps(`Campaign ${campaignId} bounce rate at ${bounceRate.toFixed(2)}%`);
  }

  return { total, bounces, bounceRate };
};

Set your alert threshold at 1.5%, not 2%. You want to catch problems before they trigger enforcement from Google, Yahoo, or Microsoft. By the time you are at 2%, damage is already being done.

The best way to keep bounce rates low is to prevent bad addresses from entering your list in the first place. Run email validation at the point of collection and periodically against your existing list. We explain how in our guide to validating email addresses before sending.

Handling complaints

Complaint events (email.complained) fire when a recipient clicks "Report Spam" in their email client. This is worse than a bounce. A bounce means the address is bad. A complaint means a real person actively does not want your email.

const handleComplaint = async (data: { recipient: string }) => {
  await db.suppressionList.upsert({
    where: { email: data.recipient },
    create: {
      email: data.recipient,
      reason: 'complaint',
      suppressedAt: new Date(),
    },
    update: {
      reason: 'complaint',
      suppressedAt: new Date(),
    },
  });

  await db.contact.update({
    where: { email: data.recipient },
    data: { status: 'unsubscribed', unsubscribeReason: 'spam_complaint' },
  });
};

Suppress immediately. Do not send a "sorry to see you go" email. Do not add them to a re-engagement flow. They reported you as spam. The only correct response is to stop emailing them.

Google's postmaster tools show complaint rates separately from bounce rates, and they weight complaints more heavily. A complaint rate above 0.1% is a red flag. Above 0.3% and you are in serious trouble.

Putting it all together

Proper bounce handling is a pipeline: authenticate your emails with SPF, DKIM, and DMARC, validate addresses before they enter your list, process bounces and complaints in real time via webhooks, maintain a suppression list, and monitor your rates.

Sendkit handles the heavy lifting — automatic suppression, webhook delivery, authentication setup. Your job is to wire up the webhook endpoint, add your custom business logic, and keep an eye on the metrics.

The full webhook documentation is at docs.sendkit.dev/webhooks/introduction. Set up your endpoint, deploy the handler code from this guide, and stop sending to dead addresses.

Share this article