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)
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
- Go to SendGrid Settings โ Mail Settings โ Event Webhook
- Enter your webhook URL:
https://yourdomain.com/webhooks/sendgrid/bounce - Enable these events: Bounce, Dropped, Spam Report
- 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:
- Go to Mailgun Dashboard โ Sending โ Webhooks
- Add webhook URL:
https://yourdomain.com/webhooks/mailgun/bounce - 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
- Syntax check: Ensure email format is valid (regex)
- MX record lookup: Verify the domain accepts email
- SMTP verification: Check if the mailbox exists (simulated send)
- Disposable email detection: Block temp email services - see our disposable email detection guide
- 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.
- API: Retrieve Bounces API
- Webhooks:
bounce,droppedevents
Mailgun
Mailgun tracks bounces automatically and stops sending to addresses with 3+ hard bounces.
- API: Suppressions API
- Webhooks:
failed,bouncedevents
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):
[email protected]โ Hard bounce[email protected]โ Soft bounce[email protected]โ Spam complaint
2. Send to invalid addresses:
[email protected]โ Domain not found (hard bounce)[email protected]โ User unknown (hard bounce)
3. Use webhook testing tools:
- Webhook.site - Inspect webhook payloads
- ngrok - Expose local server for testing
โ 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:
- Set up webhooks for real-time bounce notifications
- Classify bounces correctly (hard vs soft vs block)
- Remove hard bounces immediately - never retry them
- Retry soft bounces 3-5 times, then remove
- Prevent bounces with pre-send email validation
- 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 FreeRelated Articles
- Hard Bounce vs Soft Bounce: Complete Guide
- SMTP Bounce Codes Guide: Complete Reference
- Email Verification API Tutorial (Python + JavaScript)
- Email Deliverability Guide 2024
- โ Back to Emails Wipes Homepage
Related Articles
๐ง emails-wipes.com
Learn the difference between hard bounces and soft bounces, how to identify them, and best practices for managing bounce...
January 28, 2026 ยท 11 min readComplete Guide to SMTP Bounce Codes and Error Messages
Decode every SMTP bounce code and error message. Learn the difference between 4xx and 5xx errors, how to fix them, and w...
January 02, 2026 ยท 12 min readHow to Reduce Email Bounce Rate by 90%
Learn proven strategies to reduce your email bounce rate by up to 90%. Understand hard vs soft bounces, fix deliverabili...
February 09, 2026 ยท 10 min read