Hard Bounce vs Soft Bounce: The Critical Difference
Hard bounces are permanent delivery failures — the recipient address is invalid, the domain doesn't exist, or the mailbox is closed. The ISP's mail server has definitively told you: "This address will never accept mail."
Soft bounces are temporary failures — the mailbox is full, the receiving server is temporarily overloaded, or the message was flagged as too large. The ISP is saying: "Try again later."
The rule: Hard bounces must be suppressed immediately. Soft bounces require escalation logic (multiple retries → hard bounce → suppress).
Detailed Bounce Code Classification
RFC 3463 Enhanced Mail Status Codes (standardized across most MTAs):
| Code | Class | Meaning | Action |
|---|---|---|---|
| X.1.0 | Hard | Generic address error | Immediate suppress |
| X.1.1 | Hard | Invalid mailbox | Immediate suppress |
| X.1.2 | Hard | Invalid domain | Immediate suppress |
| X.1.3 | Hard | Destination mailbox not found | Immediate suppress |
| X.2.0 | Soft | Mailbox full | Retry, escalate after 3 |
| X.2.1 | Soft | Message too large | Retry, reduce size |
| X.2.2 | Soft | Storage full (system) | Retry, monitor |
| X.3.0 | Soft | System not accepting messages | Retry later |
| X.4.0 | Soft | Network congestion | Retry, backoff |
| X.5.0 | Soft | Routing error | Retry, check DNS |
| X.6.0 | Soft | Delivery time expired | Retry once |
Bounce Rate Benchmarks by Industry
| Industry | Acceptable Bounce | Target Bounce |
|---|---|---|
| E-commerce / Retail | < 3% | < 1.5% |
| SaaS / Software | < 2% | < 1% |
| Financial Services | < 1% | < 0.5% |
| Media / Publishing | < 4% | < 2% |
| Agency / Marketing | < 5% | < 2% |
If your bounce rate exceeds 3%, stop sending immediately and investigate. Every bounce you send to a closed mailbox is a complaint signal to the ISP.
Email Verification API Integration
The most effective way to prevent bounces is to never add invalid addresses to your list in the first place. Real-time email verification catches typos, dead domains, and spam traps before they enter your queue.
Verification API Comparison
| Provider | Accuracy | Speed | Bulk API | Pricing |
|---|---|---|---|---|
| ZeroBounce | 99% | < 1s | Yes | $0.003/verify |
| NeverBounce | 98%+ | < 1s | Yes | $0.01/verify |
| AbstractAPI | 95% | < 1s | Yes | $0.002/verify |
| Hunter | 90% | < 2s | Yes | $0.001/verify |
| MailboxValidator | 95% | < 1s | Yes | $0.005/verify |
KumoMTA Bounce Processing Lua Integration
-- /etc/kumomta/bounce_handler.lua
-- Real-time bounce classification and suppression
local SUPPRESSION_LIST = {}
local BOUNCE_STATS = { hard = 0, soft = 0, processed = 0 }
-- Load suppression list from file/db on startup
local function load_suppression_list()
local f = io.open("/var/lib/kumomta/suppression.txt", "r")
if f then
for line in f:lines() do
SUPPRESSION_LIST[line] = true
end
f:close()
log_info("Loaded " .. #SUPPRESSION_LIST .. " suppressed addresses")
end
end
local function add_to_suppression(email)
SUPPRESSION_LIST[email] = true
-- Persist to disk
local f = io.open("/var/lib/kumomta/suppression.txt", "a")
if f then
f:write(email .. "\n")
f:close()
end
end
local function is_suppressed(email)
return SUPPRESSION_LIST[email] == true
end
-- Bounce code classification
local function classify_bounce(smtp_response)
local code = tonumber(smtp_response:match("%d+")) or 0
-- RFC 3463 bounce classes
if code >= 500 or (code >= 400 and code < 500 and string.find(smtp_response, "user unknown")) then
return "hard"
elseif code >= 400 then
return "soft"
else
return "unknown"
end
end
-- Main bounce handler
kumo.on("smtp_delivery_result", function(result, meta)
BOUNCE_STATS.processed = BOUNCE_STATS.processed + 1
local bounce_type = classify_bounce(result.message)
if bounce_type == "hard" then
BOUNCE_STATS.hard = BOUNCE_STATS.hard + 1
add_to_suppression(meta.rcpt_to)
log_warn("Hard bounce: " .. meta.rcpt_to .. " - " .. result.message)
elseif bounce_type == "soft" then
BOUNCE_STATS.soft = BOUNCE_STATS.soft + 1
-- Log for escalation tracking
track_soft_bounce(meta.rcpt_to, result.code)
end
-- Report metrics
if BOUNCE_STATS.processed % 100 == 0 then
local bounce_rate = (BOUNCE_STATS.hard / BOUNCE_STATS.processed) * 100
log_info("Bounce stats: " .. BOUNCE_STATS.hard .. " hard, " ..
BOUNCE_STATS.soft .. " soft, rate: " ..
string.format("%.2f", bounce_rate) .. "%")
end
end)
-- Pre-send check
kumo.on("smtp_message_received", function(domain, meta)
local recipient = meta.rcpt_to
if is_suppressed(recipient) then
log_warn("Suppressed address rejected: " .. recipient)
kumo.reject_recipient("550 5.1.1 Address suppressed")
end
end)
load_suppression_list()
Pre-Send Verification with HTTP API
-- Real-time verification before accepting into queue
local function verify_email_address(email)
local http = require("socket.http")
local ltn12 = require("ltn12")
local json = require("cjson")
local response = {}
local req = http.request{
url = "https://api.zerobounce.net/v2/validate?email=" .. email .. "&apikey=YOUR_KEY",
sink = ltn12.sink.table(response),
}
if req then
local data = json.decode(table.concat(response))
if data.status == "valid" or data.status == "catch-all" then
return true
else
return false
end
end
return true -- Fail open if API is unreachable
end
Suppression List Architecture
A suppression list is your authoritative record of addresses that should never receive mail. It differs from a blocklist — suppression is for your own protection, not for blocking external senders.
Suppression Triggers (Automated)
| Trigger | Threshold | Action |
|---|---|---|
| Hard bounce (any code 5xx with user unknown) | 1 occurrence | Immediate suppress |
| Soft bounce | 3+ occurrences in 30 days | Suppress |
| Complaint feedback loop | 1 occurrence | Immediate suppress |
| Manual unsubscribe | Immediate | Add to unsubscribe list |
| ESP-related spam trap hit | 1 occurrence | Immediate suppress |
Suppression List File Format
# Comment: Suppressed addresses - updated 2026-05-18
bounced-user@example.com
complained-user@spamtrap.net
unsubscribed@legitimate.com
mailbox-full-123@outlook.com
Syncing Suppression Across Platforms
If you use multiple MTAs (e.g., KumoMTA for outbound, SendGrid for transactional), synchronize suppression lists via webhook:
# Webhook receiver for bounce/complaint events
import json
def handle_webhook(event):
if event['type'] == 'bounce':
add_to_suppression(event['email'], reason='hard_bounce')
elif event['type'] == 'complaint':
add_to_suppression(event['email'], reason='complaint')
# Propagate to all MTAs
for mta in all_mtas:
mta.sync_suppression(event['email'])
PowerMTA Bounce Configuration
# /etc/pmta/config
# Bounce classification
bounce检查-level up to 2
bounce-interval 15m
# Hard bounce rules
add-parts-filter bounce-checker
match-command RCPT TO (.*) (.*)
body-match "User unknown" do
bounce [code] [enhanced-code]
log-bounce
suppress
/match
# Soft bounce rules
match-command RCPT TO (.*) (.*)
body-match "Mailbox full" do
tempfail
retry 3 times every 15 minutes
/match
# Feedback loop processing
feedbackloop <your-company@postmaster.com>
add-feedback-filter fbl
class auto
source postmaster
List Hygiene Schedule
Proactive list hygiene prevents bounces before they happen:
| Schedule | Action |
|---|---|
| Real-time | Verify new signups via API |
| Daily | Remove hard bounces from active lists |
| Weekly | Re-verify addresses inactive > 30 days |
| Monthly | Full list re-verification via bulk API |
| Quarterly | Engagement-based segmentation |
Engagement-Based Re-Engagement Campaign
Addresses that haven't opened or clicked in 90+ days are high risk. Run a re-engagement campaign before sending your next major campaign:
- Send re-engagement email (clear value prop, one-click action)
- Non-responders after 2 emails → move to "at-risk" segment
- At-risk segment → 30-day cooldown
- Still no engagement → suppress permanently
Monitoring Bounce Rate in Real Time
Key Dashboards to Build
- Overall bounce rate (hard + soft, rolling 24h)
- Hard bounce rate by ISP (spot problematic ESPs fast)
- Soft bounce retry success rate (are retries working?)
- Bounce rate by campaign (which campaigns have worst lists?)
- Suppression list growth rate (is list quality improving?)
Alerting Thresholds
| Metric | Warning | Critical | Action |
|---|---|---|---|
| Overall bounce rate | > 2% | > 3% | Pause sending |
| Hard bounce rate | > 1% | > 2% | Immediate pause |
| Soft bounce rate | > 5% | > 10% | Investigate ISP |
| Bounce rate by ISP | > 5% | > 10% | Check authentication |
| Suppression growth | > 10%/day | > 20%/day | Audit acquisition |
FAQ
Q: Is a 2% bounce rate acceptable?
A: For most industries, yes. For financial services or healthcare, target < 1%. Anything above 3% risks ISP penalties.
Q: Should I delete hard bounced addresses from my database or just suppress?
A: Suppress, never delete. Suppressed addresses are flagged but retained for audit purposes. Deleted addresses might be re-registered by a new user and cause problems if accidentally re-added.
Q: How long should I retry soft bounces before suppressing?
A: 3-5 retries over 24-72 hours is standard. If the address is still failing after that, suppress as a "soft bounce exhaustion" address.
Q: Does KumoMTA handle bounces differently than PowerMTA?
A: Both handle bounce codes correctly. KumoMTA's Lua policy gives you more granular control over bounce classification and suppression logic. PowerMTA uses XML configuration for bounce rules.
Q: Can I re-send to an address I previously suppressed?
A: Only if the user re-subscribes or explicitly confirms the address is valid. Suppression exists to protect your reputation — bypassing it manually is risky.
Get Help With Bounce Rate Reduction
PostMTA provides:
- Full bounce rate audit and root cause analysis
- KumoMTA bounce processing configuration
- Suppression list architecture and integration
- List hygiene automation and verification API setup
- Real-time alerting and monitoring dashboards
👉 Talk to a deliverability expert →
For related guides, see IP Warmup Strategies, Email Authentication Guide, and SMTP Relay Setup Guide.
References: RFC 3463 (Enhanced Mail Status Codes) | Google Postmaster Tools
Top comments (0)