Most data-quality problems are private. A wrong job title embarrasses one rep in one conversation. A bounced email is different: it gets counted by somebody else. Your email provider tracks it, the mailbox providers track it, and when the count crosses a line, they act on everything you send, not just the one bad address.
The lines are close, and lists drift toward them on their own. ZeroBounce's 2026 Email List Decay Report, built on more than 11 billion addresses verified during 2025, found that 23 percent of a typical list decays within a year. A list you enriched last year is already a different list.
This post covers where the thresholds sit, what crossing them costs, and the code that keeps a send under them: pick the right address from an enrichment response, triage it by validation status, and stop a campaign automatically before the bounce rate does damage. Code is in Python and JavaScript.
Where the lines are
Most email providers don't publish their exact limits. Mailchimp's help center, for example, says only that suspensions trigger when a send exceeds industry thresholds. A few are explicit, and they set the practical bar for everyone.
Amazon SES publishes its numbers in its enforcement FAQ. It recommends keeping your bounce rate below 2 percent. At 5 percent it places your account under review, and at 10 percent it may pause your sending until you fix the cause. Only hard bounces count: permanent failures such as "address does not exist," not a full mailbox.
The mailbox providers set the other half of the bar. Google's sender guidelines ask anyone sending more than 5,000 messages a day to Gmail to authenticate with SPF, DKIM, and DMARC, to support one-click unsubscribe, and to keep the spam rate reported in Postmaster Tools below 0.1 percent, never reaching 0.3 percent. Microsoft applies the same authentication bar to domains sending 5,000 or more messages to Outlook.com and its other consumer services, and mail that fails it is rejected outright with error 550 5.7.515.
What crossing them costs
A bad address costs you one wasted send. A bad bounce rate costs you placement on every send after it, including the ones to people who wanted your email.
Validity's 2025 Email Deliverability Benchmark, covering 2024, puts the global inbox placement rate at 83.5 percent. The rest split between the spam folder (6.7 percent) and messages that never arrived at all (9.8 percent). Placement also varies sharply by provider: 87.2 percent at Gmail against 75.6 percent at Microsoft. And the trend is heading the wrong way: global spam placement almost doubled between the first and fourth quarters of 2024.
The arithmetic on an unmaintained list is short. Take 10,000 addresses enriched a year ago. If 23 percent have gone bad and even a third of those hard bounce, the next send to the full list bounces at about 7.7 percent. That is past SES's review line on the first campaign, and it lands during the same send that mailbox providers are using to decide where your next one goes.
Pick a sendable address
Prevention starts at the lookup. A person enrichment response carries every email it knows for a person, each with a type, a current flag, a confidence level based on how many sources agree, and a validation_status where the address has been checked. The top-level work_email is a convenience. For sending, read the array.
import os
import requests
def enrich_emails(email: str) -> list[dict]:
resp = requests.post(
"https://api.datalegion.ai/person/enrich",
headers={"API-Key": os.environ["DATALEGION_API_KEY"]},
json={"email": email, "include_fields": "full_name,emails"},
timeout=5,
)
if resp.status_code == 404:
return []
resp.raise_for_status()
matches = resp.json().get("matches", [])
return matches[0]["person"].get("emails", []) if matches else []
def pick_sendable(emails: list[dict]) -> dict | None:
work = [e for e in emails if e.get("type") == "professional" and e.get("current")]
if not work:
return None
# A confirmed-valid address wins outright.
for e in work:
if e.get("validation_status") == "valid":
return e
# Otherwise prefer the address the most sources agree on.
rank = {"high": 0, "moderate": 1, "low": 2}
return min(work, key=lambda e: rank.get(e.get("confidence"), 3))async function enrichEmails(email) {
const resp = await fetch("https://api.datalegion.ai/person/enrich", {
method: "POST",
headers: {
"API-Key": process.env.DATALEGION_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ email, include_fields: "full_name,emails" }),
signal: AbortSignal.timeout(5000),
});
if (resp.status === 404) return [];
if (!resp.ok) throw new Error(`enrich failed: ${resp.status}`);
const { matches = [] } = await resp.json();
return matches.length ? matches[0].person.emails ?? [] : [];
}
function pickSendable(emails) {
const work = emails.filter((e) => e.type === "professional" && e.current);
if (work.length === 0) return null;
// A confirmed-valid address wins outright.
const valid = work.find((e) => e.validation_status === "valid");
if (valid) return valid;
// Otherwise prefer the address the most sources agree on.
const rank = { high: 0, moderate: 1, low: 2 };
return work.reduce((best, e) =>
(rank[e.confidence] ?? 3) < (rank[best.confidence] ?? 3) ? e : best,
);
}The current flag is doing quiet work here. A person who changed jobs keeps their old work address in the array, marked current: false. Some of those old addresses were valid when last checked, but they reach a mailbox the person no longer reads, and they are the ones most likely to start hard bouncing. Filtering on current drops them before they reach your sender.
Triage by validation status
Where an address has been checked, validation_status tells you what the check found, and each value calls for a different action. Where it hasn't, the field is empty, and that is a decision too.
def triage(email_obj: dict) -> str:
status = email_obj.get("validation_status")
if status == "valid":
return "send"
if status == "catch_all":
return "send_small_batch" # the domain accepts everything, so watch bounces
if status == "risky":
return "hold"
if status == "invalid":
return "suppress"
return "verify_first" # not yet checked: run it through your verifierfunction triage(emailObj) {
switch (emailObj.validation_status) {
case "valid":
return "send";
case "catch_all":
return "send_small_batch"; // the domain accepts everything, so watch bounces
case "risky":
return "hold";
case "invalid":
return "suppress";
default:
return "verify_first"; // not yet checked: run it through your verifier
}
}A catch_all domain accepts mail for any address, so a check can't confirm the specific mailbox exists. Those are worth sending, but in smaller batches where a spike shows up fast. For unchecked addresses, a mailbox-level verification pass before the first send is cheap insurance. One distinction worth knowing: the free utility endpoints clean and format-check an address, which catches typos and malformed input, but they do not test whether the mailbox exists. They belong at the form, and a deliverability check belongs before the send.
Stop the send before the damage
Even a clean list can surprise you. A domain migrates, a company shuts down, a batch of addresses was typed by hand. The last line of defense is a circuit breaker in your sending loop: count hard bounces as they come back, and halt the campaign when the rate crosses a limit you set below the providers' lines.
class BounceBreaker:
def __init__(self, limit: float = 0.02, min_sends: int = 200):
self.limit = limit
self.min_sends = min_sends
self.sent = 0
self.hard_bounces = 0
def record_send(self) -> None:
self.sent += 1
def record_hard_bounce(self) -> None:
self.hard_bounces += 1
def tripped(self) -> bool:
if self.sent < self.min_sends:
return False # too few sends to judge
return self.hard_bounces / self.sent > self.limitclass BounceBreaker {
constructor(limit = 0.02, minSends = 200) {
this.limit = limit;
this.minSends = minSends;
this.sent = 0;
this.hardBounces = 0;
}
recordSend() {
this.sent += 1;
}
recordHardBounce() {
this.hardBounces += 1;
}
tripped() {
if (this.sent < this.minSends) return false; // too few sends to judge
return this.hardBounces / this.sent > this.limit;
}
}Wire record_hard_bounce to your provider's bounce webhook, check tripped() between batches, and pause the campaign when it fires. A 2 percent trip leaves room under SES's review line and gives you time to find the bad segment before a provider decides for you.
Keep the list from drifting
The circuit breaker catches a bad list. Refreshing the list keeps it from going bad. Re-enrich addresses before a campaign when their last enrichment is more than a few months old, and suppress anything that no longer comes back as a current work address. The CRM Data Hygiene Playbook lays out that cadence, and What Makes Person Data Actually Reliable covers why refresh schedules matter more than record counts.
Bounce rate is one of the few data-quality metrics with a published, enforced threshold and a cost that lands on every future send. That makes it the easiest one to justify fixing. The fields that make the fix possible, current and confidence on every email and validation_status where a check has run, are documented at www.datalegion.ai/docs/person-data/schema.
Build it with a coding agent
To scaffold a pre-send filter and bounce breaker, paste this into Claude Code, Codex, or Cursor:
Build a pre-send email filter and bounce circuit breaker using the Data
Legion Person Enrich API.
- POST https://api.datalegion.ai/person/enrich
- Header: API-Key: $DATALEGION_API_KEY
- Body: {"email": "<address>", "include_fields": "full_name,emails"}
- Read matches[0].person.emails. Each email has type ("professional" or
"personal"), current (work emails), confidence ("high", "moderate",
"low"), and validation_status ("valid", "invalid", "risky", "catch_all",
or null when not yet checked). A 404 means no match.
References for context (read these first):
- The walkthrough this is based on:
https://www.datalegion.ai/blog/post/cost-of-a-bad-email
- Person Enrich API: https://www.datalegion.ai/docs/api-reference/person-enrichment
- Person field schema: https://www.datalegion.ai/docs/person-data/schema
- Amazon SES bounce thresholds:
https://docs.aws.amazon.com/ses/latest/dg/faqs-enforcement.html
Requirements:
- For each contact, pick one sendable address: a current professional email,
preferring validation_status "valid", then the highest confidence.
- Triage each address: send, send in a small batch (catch_all), hold (risky),
suppress (invalid), or verify first (no status).
- A bounce circuit breaker that pauses a campaign when hard bounces exceed a
configurable rate (default 2%) after a minimum number of sends.
- Stay under 100 enrich requests per minute.
Before you start, ask me which language and framework to use. Sketch a short
plan, then build it with tests and a short README. When you finish, check the
result against each requirement above and tell me what is covered.