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