Three months ago a founder woke up to find his Instantly account suspended. Bounce rate: 6.8%. Suspension threshold: 5%. He'd been running sequences against 4,200 contacts scraped from LinkedIn 14 months earlier. Nobody told him B2B email addresses decay at ~30% per year. Nobody told him his CRM was quietly becoming a liability.
The bounce rate had been climbing for months — 2%, then 3.5%, then 5.2%. He'd seen the numbers. He didn't know what to do, so he kept sending. Then the ESP pulled the plug on a Tuesday morning.
That's the trap. There's no error message. No red light. Just a slow accumulation of bad addresses until one day your ESP decides you've become a problem.
Why B2B Email Addresses Go Stale
B2B email is structurally less stable than consumer email. People change jobs. Companies restructure. Domains get retired.
The decay clusters into four types:
- Job-change decay: Contact moves to a new company with a new email. Old address hard bounces.
- Company restructuring: Acquisitions, rebrands, shutdowns. Domain-level decay hits every contact at that company simultaneously.
- Role turnover: Your champion at a target account gets promoted out. Their email no longer reaches anyone useful.
- Catch-all domains: Some company domains accept all incoming mail. Delivering there doesn't mean it reaches a person.
The invisible part: none of this shows up until you send. And by the time you've sent enough to register a 5% bounce rate, you're one sequence away from suspension.
Step 1: Segment Your Contacts by Age
Start with the obvious — how old is your data?
import csv
from datetime import datetime
from collections import Counter
def segment_by_age(contacts_path):
now = datetime.now()
buckets = {"fresh (<90d)": [], "aging (90-180d)": [],
"stale (180-365d)": [], "ancient (>365d)": []}
with open(contacts_path, newline="") as f:
for row in csv.DictReader(f):
age = (now - datetime.strptime(row["import_date"], "%Y-%m-%d")).days
if age < 90: buckets["fresh (<90d)"].append(row["email"])
elif age < 180: buckets["aging (90-180d)"].append(row["email"])
elif age < 365: buckets["stale (180-365d)"].append(row["email"])
else: buckets["ancient (>365d)"].append(row["email"])
for label, emails in buckets.items():
print(f"{label}: {len(emails)} contacts")
return buckets
contacts = segment_by_age("crm_export.csv")
Ancient segments (365+ days) are highest risk — assume at least 30% of those addresses are dead.
Step 2: Detect Domain-Level Bounce Clusters
When three contacts at the same domain start bouncing simultaneously, it's rarely coincidence. The company has restructured, changed domains, or shut down.
from collections import Counter
def find_domain_clusters(bouncing_addresses):
domains = [e.split("@")[1] for e in bouncing_addresses]
clusters = {d: c for d, c in Counter(domains).items() if c >= 3}
print(f"Domain clusters: {len(clusters)}")
for domain, count in sorted(clusters.items(), key=lambda x: -x[1]):
print(f" {domain}: {count} bouncing — investigate")
return clusters
When you find a cluster, don't just remove those contacts. Check LinkedIn — have they been acquired or rebranded? You may need to re-enrich those contacts to their new company, not just suppress them.
Step 3: Find Role-Based Email Addresses
Role addresses like info@, hello@, support@, sales@ generate high spam complaint rates. They're shared inboxes, not people.
ROLE_LOCAL_PARTS = {"info", "hello", "support", "team", "contact", "sales",
"marketing", "admin", "webmaster", "noreply", "billing"}
def find_role_addresses(contacts_path):
role_emails = []
with open(contacts_path, newline="") as f:
for row in csv.DictReader(f):
local = row["email"].split("@")[0].lower()
if any(r in local for r in ROLE_LOCAL_PARTS):
role_emails.append(row["email"])
print(f"Role addresses: {len(role_emails)}")
return role_emails
These are fine for cold outreach to companies, not people. Never put them in a warm nurture sequence.
Step 4: Validate with ZeroBounce Before Sending
Format checks catch typos. For real validation, you need SMTP-level — does this mailbox actually exist?
import requests, time
def validate_batch(emails, api_key):
results = {}
for i in range(0, len(emails), 100):
batch = emails[i:i+100]
r = requests.post("https://api.zerobounce.net/v2/validatebatch",
params={"api_key": api_key},
json=[{"email": e} for e in batch])
for entry in r.json():
results[entry["address"]] = entry["status"]
time.sleep(1)
return results
results = validate_batch(stale_emails, ZEROBOUNCE_API_KEY)
from collections import Counter
print(Counter(results.values()))
# Counter({'valid': 142, 'invalid': 89, 'catch-all': 34, 'unknown': 12})
Segment catch-all domains separately — send last or at low volume. Re-validate unknown results in 30 days.
Step 5: Monitor Company Headcount Changes with Apify
Your most valuable contacts — champions at target accounts — are also the most likely to job-hop. Track their employers with Apify's LinkedIn Company Scraper:
const { ApifyClient } = require("apify-client");
const client = new ApifyClient({ token: process.env.APIFY_API_TOKEN });
async function monitorCompanies(domains) {
const run = await client.actor("linkedin/company-scraper").start({
companyUrls: domains.map(d => `https://linkedin.com/company/${d}`),
fields: ["name", "headcount", "headcountTrend"],
});
const { items } = await client.dataset(run.defaultDatasetId).listItems();
const alerts = items.filter(c => c.headcountTrend === "declining");
console.log(`Companies with headcount decline: ${alerts.length}`);
return alerts;
}
Any contact at a flagged company needs email re-verification before your next send.
Step 6: Set Your Own ESP Alert Threshold
Your ESP suspends at 5% bounce. Set your internal alert at 3% — that gives you 7–14 days to clean before hitting the suspension threshold.
def check_esp_health(esp_api_key, alert=3.0, critical=5.0):
r = requests.get("https://api.your-esp.com/account/health",
headers={"Authorization": f"Bearer {esp_api_key}"})
bounce = r.json()["bounce_rate_percent"]
print(f"Bounce rate: {bounce}%")
if bounce >= critical: return "CRITICAL — pause all sends"
elif bounce >= alert: return "WARNING — clean list before next send"
return "OK"
Step 7: Build a Validation Gate for New Imports
The root cause of most ESP suspensions is importing dirty data. Fix it with a validation gate on every new contact import.
def validate_import(csv_path, api_key):
with open(csv_path) as f:
rows = list(csv.DictReader(f))
results = validate_batch([r["email"] for r in rows], api_key)
approved = [r for r in rows if results[r["email"]] == "valid"]
quarantined = [r for r in rows if results[r["email"]] != "valid"]
print(f"Approved: {len(approved)}, Quarantined: {len(quarantined)}")
if quarantined:
with open("quarantined.csv", "w") as f:
w = csv.DictWriter(f, fieldnames=quarantined[0].keys())
w.writeheader(); w.writerows(quarantined)
return approved
No list enters the CRM without passing this gate.
Step 8: Run Quarterly Re-Validation
def quarterly_hygiene(esp_key, zb_key, crm_path):
# 1. ESP health check
if "CRITICAL" in check_esp_health(esp_key):
print("Cannot proceed — ESP suspension risk."); return
# 2. Full validation
contacts = read_csv(crm_path)
results = validate_batch([c["email"] for c in contacts], zb_key)
# 3. Suppression list
invalid = [e for e, s in results.items() if s == "invalid"]
with open("suppression.csv", "w") as f:
csv.writer(f).writerows([[e] for e in invalid])
print(f"Suppressed: {len(invalid)} — upload to ESP before next send.")
A dirty list rebuild takes 2–4 weeks. A quarterly hygiene run takes 20 minutes.
The Pre-Send Checklist
Before every campaign:
- [ ] Bounce rate under 3%? (If not, clean list first)
- [ ] Suppression list uploaded to ESP?
- [ ] Role addresses removed from this send?
- [ ] Catch-all domains in separate low-priority pool?
- [ ] List validated with ZeroBounce/NeverBounce in last 90 days?
- [ ] Domain cluster analysis run — any company-wide bounces?
- [ ] New imports validated through gate before CRM entry?
- [ ] SPF/DKIM/DMARC verified current?
Eight checks. Run them before every campaign. You will never get blacklisted.
The Real Cost
The direct cost of an ESP suspension: 2–4 weeks of paused outbound. For a company doing $20K/month in pipeline through cold email, that's $15–30K in lost opportunity.
The indirect cost: domain reputation takes 60–90 days to rebuild. Every email during recovery lands in spam.
The invisible cost: you can't trust your outbound analytics. You don't know who didn't respond because they never received your email versus because they weren't interested.
The checklist that prevents all of this takes 20 minutes quarterly. The ESP suspension takes 2–4 weeks to recover from.
Get the full 20-point checklist: B2B List Hygiene Audit — $29
Take the next step
Production-ready tools for CRM contact enrichment automation:
n8n AI Automation Pack — $39 one-time
Instant download. Documented. Ready to deploy.
Top comments (0)