Email Bounce Handling: Best Practices for Developers (2024)

Learn how to handle email bounces programmatically with code examples in Node.js, Python, and PHP. Webhook setup, bounce classification, and automated list cleaning.

Email Bounce Handling: Best Practices for Developers (2024)

MS
Max Sterling
December 17, 2025 ยท 11 min read
Published December 17, 2025

Email bounces are inevitable - but how you handle them determines your sender reputation, deliverability, and customer experience.

This guide shows you how to build a solid bounce handling system with code examples in Node.js, Python, and PHP. You'll learn:

  • How to classify bounces (hard vs soft)
  • How to set up webhooks for real-time bounce notifications
  • How to automate list cleaning based on bounce data
  • How to prevent future bounces with validation

๐Ÿ“Š Types of Email Bounces (Quick Recap)

Bounce Type SMTP Code Action
Hard Bounce 5xx โŒ Remove immediately
Soft Bounce 4xx โณ Retry 3-5 times, then remove
Block Bounce 5xx (spam-related) ๐Ÿšซ Remove + review sender reputation

Key Rule: Hard bounces = permanent failures. Remove them immediately to protect your sender reputation.

For a deeper dive, read our Hard Bounce vs Soft Bounce Complete Guide. Also see our practical strategies to reduce email bounce rate.

๐Ÿ”” Step 1: Set Up Bounce Webhooks

Most ESPs (SendGrid, Mailgun, Amazon SES, Postmark) support webhooks - HTTP callbacks triggered when an email bounces.

Why Webhooks?

  • โœ… Real-time bounce notifications (no polling needed)
  • โœ… Automatic bounce data (SMTP code, reason, timestamp)
  • โœ… Scalable (handles millions of events)

Example: SendGrid Webhook Setup

1. Create a webhook endpoint in your app

// Node.js + Express
const express = require('express');
const app = express();

app.post('/webhooks/sendgrid/bounce', express.json(), async (req, res) => {
  const events = req.body; // Array of bounce events

  for (const event of events) {
    if (event.event === 'bounce' || event.event === 'dropped') {
      await handleBounce({
        email: event.email,
        reason: event.reason,
        status: event.status, // e.g., "5.1.1"
        type: event.type,     // "bounce" or "blocked"
        timestamp: event.timestamp
      });
    }
  }

  res.sendStatus(200); // Acknowledge receipt
});

async function handleBounce(bounce) {
  console.log(`Bounce detected: ${bounce.email} - ${bounce.reason}`);

  // Classify bounce type
  const isHardBounce = bounce.status.startsWith('5.');

  if (isHardBounce) {
    // Remove from database immediately
    await db.query('UPDATE users SET email_status = ? WHERE email = ?', ['bounced', bounce.email]);
    console.log(`Removed hard bounce: ${bounce.email}`);
  } else {
    // Increment soft bounce counter
    await db.query('UPDATE users SET soft_bounce_count = soft_bounce_count + 1 WHERE email = ?', [bounce.email]);
    console.log(`Soft bounce recorded: ${bounce.email}`);
  }
}

app.listen(3000, () => console.log('Webhook server running on port 3000'));

2. Configure webhook URL in SendGrid

  1. Go to SendGrid Settings โ†’ Mail Settings โ†’ Event Webhook
  2. Enter your webhook URL: https://yourdomain.com/webhooks/sendgrid/bounce
  3. Enable these events: Bounce, Dropped, Spam Report
  4. Save and test (SendGrid will send a test event)

Example: Mailgun Webhook Setup

# Python + Flask
from flask import Flask, request
import sqlite3

app = Flask(__name__)

@app.route('/webhooks/mailgun/bounce', methods=['POST'])
def handle_mailgun_bounce():
    data = request.form  # Mailgun sends form data, not JSON

    event_type = data.get('event')
    email = data.get('recipient')
    reason = data.get('error')
    code = data.get('code')  # SMTP code (e.g., "550")

    if event_type in ['failed', 'bounced']:
        is_hard_bounce = code.startswith('5')

        if is_hard_bounce:
            # Remove from database
            conn = sqlite3.connect('emails.db')
            c = conn.cursor()
            c.execute("UPDATE users SET status = 'bounced' WHERE email = ?", (email,))
            conn.commit()
            conn.close()
            print(f"Removed hard bounce: {email}")
        else:
            # Log soft bounce
            print(f"Soft bounce: {email} - {reason}")

    return '', 200

if __name__ == '__main__':
    app.run(port=5000)

Configure in Mailgun:

  1. Go to Mailgun Dashboard โ†’ Sending โ†’ Webhooks
  2. Add webhook URL: https://yourdomain.com/webhooks/mailgun/bounce
  3. Enable events: Permanent Failure, Temporary Failure

๐Ÿ—‚๏ธ Step 2: Classify Bounces (Hard vs Soft)

Not all bounces are created equal. You need to classify them correctly to decide whether to remove or retry.

Bounce Classification Logic

// JavaScript bounce classifier
function classifyBounce(smtpCode, reason) {
  // Hard bounces (5xx codes) - permanent failure
  if (smtpCode.startsWith('5.1.1')) {
    return { type: 'hard', reason: 'Invalid email address (user unknown)' };
  }
  if (smtpCode.startsWith('5.1.2')) {
    return { type: 'hard', reason: 'Invalid domain (host unknown)' };
  }
  if (smtpCode.startsWith('5.2.1')) {
    return { type: 'hard', reason: 'Mailbox disabled' };
  }
  if (smtpCode.startsWith('5.4.1')) {
    return { type: 'hard', reason: 'Recipient address rejected' };
  }

  // Soft bounces (4xx codes) - temporary failure
  if (smtpCode.startsWith('4.2.2')) {
    return { type: 'soft', reason: 'Mailbox full' };
  }
  if (smtpCode.startsWith('4.3.1')) {
    return { type: 'soft', reason: 'Mailbox temporarily unavailable' };
  }
  if (smtpCode.startsWith('4.7.1')) {
    return { type: 'soft', reason: 'Greylisting (server temporary rejection)' };
  }

  // Block bounces (spam-related)
  if (smtpCode.startsWith('5.7.1')) {
    return { type: 'block', reason: 'Blocked by recipient (spam filter)' };
  }
  if (reason && reason.includes('spam')) {
    return { type: 'block', reason: 'Spam complaint' };
  }

  // Default: treat unknown 5xx as hard bounce
  if (smtpCode.startsWith('5')) {
    return { type: 'hard', reason: 'Unknown permanent failure' };
  }

  // Default: treat unknown 4xx as soft bounce
  return { type: 'soft', reason: 'Unknown temporary failure' };
}

// Example usage
const bounce = classifyBounce('5.1.1', 'User unknown');
console.log(bounce); // { type: 'hard', reason: 'Invalid email address (user unknown)' }

For a complete SMTP code reference, see our SMTP Bounce Codes Guide.

โ™ป๏ธ Step 3: Retry Logic for Soft Bounces

Soft bounces are temporary failures - the mailbox might be full, the server might be down, or greylisting might be active.

Best practice: Retry 3-5 times over 3-7 days, then remove if still failing.

Example: Retry Logic (Node.js)

// Retry soft bounces with exponential backoff
async function retrySoftBounce(email) {
  const user = await db.query('SELECT * FROM users WHERE email = ?', [email]);

  if (!user) return;

  const retryCount = user.soft_bounce_count || 0;
  const maxRetries = 5;

  if (retryCount >= maxRetries) {
    // Too many soft bounces - remove from list
    await db.query('UPDATE users SET status = ? WHERE email = ?', ['bounced', email]);
    console.log(`Removed after ${maxRetries} soft bounces: ${email}`);
    return;
  }

  // Calculate retry delay (exponential backoff: 1h, 6h, 24h, 72h, 168h)
  const delayHours = [1, 6, 24, 72, 168][retryCount];
  const retryAt = new Date(Date.now() + delayHours * 60 * 60 * 1000);

  // Schedule retry
  await db.query('UPDATE users SET retry_at = ?, soft_bounce_count = ? WHERE email = ?', 
    [retryAt, retryCount + 1, email]);

  console.log(`Retry scheduled for ${email} at ${retryAt}`);
}

Example: Retry Logic (Python)

# Python retry logic with exponential backoff
import sqlite3
from datetime import datetime, timedelta

def retry_soft_bounce(email):
    conn = sqlite3.connect('emails.db')
    c = conn.cursor()

    c.execute("SELECT soft_bounce_count FROM users WHERE email = ?", (email,))
    result = c.fetchone()

    if not result:
        return

    retry_count = result[0] or 0
    max_retries = 5

    if retry_count >= max_retries:
        # Remove after max retries
        c.execute("UPDATE users SET status = 'bounced' WHERE email = ?", (email,))
        conn.commit()
        conn.close()
        print(f"Removed after {max_retries} retries: {email}")
        return

    # Exponential backoff: 1h, 6h, 24h, 72h, 168h
    delay_hours = [1, 6, 24, 72, 168][retry_count]
    retry_at = datetime.now() + timedelta(hours=delay_hours)

    c.execute("UPDATE users SET retry_at = ?, soft_bounce_count = ? WHERE email = ?",
              (retry_at, retry_count + 1, email))
    conn.commit()
    conn.close()

    print(f"Retry scheduled for {email} at {retry_at}")

๐Ÿ—‘๏ธ Step 4: Automated List Cleaning

Once you've classified bounces, you need to automatically remove hard bounces and suppress future emails to those addresses.

Database Schema for Bounce Tracking

CREATE TABLE users (
  id INT PRIMARY KEY AUTO_INCREMENT,
  email VARCHAR(255) UNIQUE NOT NULL,
  status ENUM('active', 'bounced', 'unsubscribed') DEFAULT 'active',
  soft_bounce_count INT DEFAULT 0,
  last_bounce_date DATETIME,
  bounce_reason TEXT,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_status ON users(status);
CREATE INDEX idx_email ON users(email);

Example: Remove Hard Bounces (Node.js)

async function removeHardBounce(email, reason) {
  await db.query(`
    UPDATE users
    SET status = 'bounced',
        last_bounce_date = NOW(),
        bounce_reason = ?
    WHERE email = ?
  `, [reason, email]);

  console.log(`Hard bounce removed: ${email}`);
}

Example: Suppress Future Emails (PHP)

prepare("
        UPDATE users
        SET status = 'bounced',
            last_bounce_date = NOW(),
            bounce_reason = ?
        WHERE email = ?
    ");

    $stmt->execute([$reason, $email]);

    echo "Suppressed email: $email\n";
}
?>

๐Ÿ“ง Step 5: Prevent Bounces Before Sending

The best way to handle bounces is to prevent them in the first place.

Validate Emails Before Sending

Remove invalid emails, catch-all domains, and disposable addresses before they bounce.

Try Emails Wipes Free (100 emails)

Pre-Send Validation Workflow

  1. Syntax check: Ensure email format is valid (regex)
  2. MX record lookup: Verify the domain accepts email
  3. SMTP verification: Check if the mailbox exists (simulated send)
  4. Disposable email detection: Block temp email services - see our disposable email detection guide
  5. Catch-all detection: Flag domains that accept all addresses - learn about catch-all email risks

Read our Email Verification API Tutorial for code examples.

๐Ÿ” Step 6: Monitor Bounce Rates

Track your bounce rate over time to identify trends and problems early.

Bounce Rate Status Action
< 2% โœ… Excellent No action needed
2% - 5% โš ๏ธ Warning Clean your list
> 5% โŒ Critical Immediate list cleaning required

Example: Calculate Bounce Rate (SQL)

SELECT
  COUNT(*) AS total_emails,
  SUM(CASE WHEN status = 'bounced' THEN 1 ELSE 0 END) AS bounced_emails,
  ROUND(SUM(CASE WHEN status = 'bounced' THEN 1 ELSE 0 END) / COUNT(*) * 100, 2) AS bounce_rate_percent
FROM users
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY);

โš™๏ธ Step 7: ESP-Specific Bounce Handling

SendGrid

SendGrid automatically suppresses hard bounces after 3 attempts. You can view suppression lists in the dashboard.

Mailgun

Mailgun tracks bounces automatically and stops sending to addresses with 3+ hard bounces.

Amazon SES

SES uses SNS (Simple Notification Service) for bounce notifications.

  • Setup: Configure SNS topic โ†’ Subscribe HTTPS endpoint โ†’ Parse JSON notifications
  • Docs: SES Bounce Notifications

Postmark

Postmark has the most developer-friendly bounce handling.

  • API: Bounces API
  • Webhooks: Real-time bounce notifications with detailed classification

๐Ÿงช Step 8: Test Your Bounce Handling

Before going live, test your bounce handling logic with fake bounces.

How to Trigger Test Bounces

1. Use ESP test addresses (SendGrid example):

2. Send to invalid addresses:

3. Use webhook testing tools:

โœ… Bounce Handling Checklist

Setup

  • โ˜ Webhook endpoint created and deployed
  • โ˜ Webhook URL configured in ESP settings
  • โ˜ Database schema includes bounce tracking fields
  • โ˜ Bounce classification logic implemented

Automation

  • โ˜ Hard bounces removed immediately
  • โ˜ Soft bounces retried 3-5 times with exponential backoff
  • โ˜ Bounce rate monitored (target: < 2%)
  • โ˜ Suppression list synced with ESP

Prevention

  • โ˜ Email validation before adding to database
  • โ˜ Disposable email detection enabled
  • โ˜ Double opt-in for new subscribers
  • โ˜ Regular list cleaning (monthly or quarterly)

Testing

  • โ˜ Test bounces sent and handled correctly
  • โ˜ Webhook payloads logged and inspected
  • โ˜ Database updates verified
  • โ˜ No emails sent to suppressed addresses

๐Ÿš€ Final Thoughts

Proper bounce handling is critical for maintaining sender reputation and deliverability. Review our complete email deliverability checklist to ensure all aspects are covered. Follow these best practices:

  1. Set up webhooks for real-time bounce notifications
  2. Classify bounces correctly (hard vs soft vs block)
  3. Remove hard bounces immediately - never retry them
  4. Retry soft bounces 3-5 times, then remove
  5. Prevent bounces with pre-send email validation
  6. Monitor bounce rates weekly (target: < 2%)

Stop Bounces Before They Happen

Validate your email list in seconds with Emails Wipes - the fastest & most affordable email verification service.

Validate 100 Emails Free

Related Articles