The most-quoted ranges put B2B contact decay at roughly 10% to 70% a year, depending on sector. LeadMagic's guide (September 2026) says plainly that these are vendor estimates, not audited benchmarks.
Two problems for a technical buyer. A vendor figure may count any stale field, including title and phone, not only the email address. And a per-year rate hides what you need: how much of a segment you can still mail next month.
Convert a yearly rate to a monthly one with compounding, not division by 12.
annual = 0.35 # 35% a year
monthly = 1 - (1 - annual) ** (1 / 12)
print(f"{monthly:.1%} a month") # 3.5% a monthNow measure your own rate. The steps below use three example industries: Software Development, Hospitals and Health Care, and Government Administration.
TL;DR
- Decay rates by industry are published as vendor estimates, not audited benchmarks. The honest number is the one you measure on your own segments.
- It takes about 30 minutes today and 10 minutes after 30 days. The panel spends about 900 of your 1,000 free records. The status snapshot spends none.
- LeadOcean gives you two measures: the email status mix per industry (free counts) and the status change of a fixed panel of people (enrich by
person_id).
Prerequisites
- A LeadOcean API key from app.leadocean.io. The free plan has no card and 1,000 records, one-off. The key needs the search and enrich scopes, which Free keys get.
- The key in your shell as
$LEADOCEAN_API_KEY. curl,jqandpython3.
export LEADOCEAN_API_KEY="paste-your-key-here"Step 1: Resolve the exact industry strings
Industry is a closed list of 534 values. A wrong string returns an empty result, not an error you will notice. Look the values up first. The call is free.
curl -s "https://api.leadocean.io/v1/enums/industry?q=hospital" \
-H "x-api-key: $LEADOCEAN_API_KEY" | jq '.data.values'In an MCP client the same lookup is one prompt.
Use leadocean_list_enum_values with name "industry" and q "government".
Do not call any tool that spends records.Pick one value per segment and write it down exactly, for example Hospitals and Health Care.
Step 2: Snapshot the status mix per industry (free)
Count each industry by email status. A search with count=true and limit 1 returns meta.total and no rows, and it costs nothing. This example fixes the audience to US C-Team contacts so the three industries compare like for like.
for IND in "Software Development" "Hospitals and Health Care" "Government Administration"; do
for BUCKET in '["verified"]' '["catch_all","catch_all_valid"]' '["risky","unknown","untested"]'; do
TOTAL=$(curl -s "https://api.leadocean.io/v1/people/search?count=true" \
-H "x-api-key: $LEADOCEAN_API_KEY" -H "content-type: application/json" \
-d "{\"industry\":[\"$IND\"],\"country\":[\"US\"],\"jobLevel\":[\"C-Team\"],\"emailStatus\":$BUCKET,\"limit\":1}" \
| jq '.meta.total')
echo "$(date +%F),$IND,$BUCKET,$TOTAL" >> snapshot.csv
done
doneNote that REST meta.total is capped at 100,000. If a bucket reaches it, split the audience by employeeRange or country. The free MCP tool leadocean_count_leads is not held to that cap. On 21 October 2026 it returned 178,503 with totalIsExact true.
The dataset is refreshed monthly, so run this loop once a month and keep the file. Each run adds nine dated rows. The change in the mix between runs is your first decay signal, and it costs 0 records.
Step 3: Pull a fixed panel of people
A count shows the mix, not what happened to the same people. For that you need a panel: the same person ids, checked twice. Search 100 verified people per industry and keep their ids.
for IND in "Software Development" "Hospitals and Health Care" "Government Administration"; do
curl -s https://api.leadocean.io/v1/people/search \
-H "x-api-key: $LEADOCEAN_API_KEY" -H "content-type: application/json" \
-d "{\"industry\":[\"$IND\"],\"country\":[\"US\"],\"jobLevel\":[\"C-Team\"],\"emailStatus\":[\"verified\"],\"limit\":100}" \
| jq -r --arg ind "$IND" '.data[] | [$ind, .meta.source_ids.person_id] | @csv' >> panel.csv
done
wc -l panel.csvThis spends 300 records, one per person returned. Search returns thin rows with no addresses. Each row carries meta.source_ids.person_id, which is the key for the next step.
Search counts verified on a broader rule than enrich does, so treat the search count as an upper bound. The next step shows the true baseline.
Step 4: Record the baseline status by person_id
Enrich each id once and store the status. POST /v1/people/enrich takes exactly one identifier, here person_id. It costs one record per person. A person we hold nothing for is a 404 and costs nothing.
DATE=$(date +%F)
while IFS=, read -r IND PID; do
PID=${PID//\"/}; IND=${IND//\"/}
STATUS=$(curl -s https://api.leadocean.io/v1/people/enrich \
-H "x-api-key: $LEADOCEAN_API_KEY" -H "content-type: application/json" \
-d "{\"person_id\":\"$PID\"}" | jq -r '.data.contact_data.email_status // "404"')
echo "$DATE,$IND,$PID,$STATUS" >> baseline.csv
done < panel.csv
cut -d, -f2,4 baseline.csv | sort | uniq -cSome of the 300 will already read untested or catch_all. That is the search-versus-enrich gap, and it is a real finding about the segment. Drop those from the denominator in Step 6.
Step 5: Re-check the same people after 30 days
Run the same enrich loop on the same ids. Do not re-search: a new search returns different people. Write the result to a second file.
DATE=$(date +%F)
while IFS=, read -r DAY IND PID OLD; do
STATUS=$(curl -s https://api.leadocean.io/v1/people/enrich \
-H "x-api-key: $LEADOCEAN_API_KEY" -H "content-type: application/json" \
-d "{\"person_id\":\"$PID\"}" | jq -r '.data.contact_data.email_status // "404"')
echo "$DATE,$IND,$PID,$STATUS" >> recheck.csv
done < baseline.csvThis spends up to 300 more records, about 900 in total. Wait at least one monthly refresh before you read the result, because the data changes on that cycle.
Step 6: Compute decay per industry
Your measured rate is the share of people who were verified at baseline and are not verified now. People who left the data entirely (a 404 on the re-check) count as decayed.
import csv, collections
base = {r[2]: (r[1], r[3]) for r in csv.reader(open("baseline.csv"))}
now = {r[2]: r[3] for r in csv.reader(open("recheck.csv"))}
ok, lost = collections.Counter(), collections.Counter()
for pid, (ind, s0) in base.items():
if s0 != "verified": continue
ok[ind] += 1
if now.get(pid) != "verified": lost[ind] += 1
for ind in ok:
print(f"{ind}: {lost[ind]}/{ok[ind]} = {lost[ind]/ok[ind]:.1%} in the window")Read it as a rate for the window you waited, not a year. Convert with the compounding formula above only if you ran at least 90 days.
Then set the cadence from the rate. Pick the invalid share you will tolerate on a send, and re-verify a segment before its measured loss reaches it. A segment that loses 3% in a month needs a monthly check. One that loses 3% in a quarter needs one a quarter. Keep the per-industry numbers in a table next to the segment tags in your CRM, and re-run the panel twice a year to see whether they drift. A job change moves a person out of the segment, but their old address may keep accepting mail for a while, so status change understates job churn.
What you get
Step 2 is free, so we ran it as MCP counts. These are the US C-Team counts by email status bucket on 21 October 2026. They describe what we filtered, not a mailable audience: the last bucket is risky, unknown and untested addresses.
| Industry | verified | catch_all or catch_all_valid | risky, unknown or untested | verified share of the three |
|---|---|---|---|---|
| Software Development | 39,062 | 91,448 | 105,972 | 16.5% |
| Hospitals and Health Care | 32,924 | 151,508 | 178,503 | 9.1% |
| Government Administration | 11,832 | 73,507 | 70,499 | 7.6% |
This is one snapshot, so it shows where each industry stands today and no rate. Run the loop again next month, and the difference in the verified share is the first measured movement. Treat the verified counts as an upper bound until you confirm them per address on enrich.
The enrich call in Step 4 and Step 5 returns a person record. This is the response shape from the API reference, with placeholder values.
{
"success": true,
"data": {
"profile_data": {},
"contact_data": {
"has_email": true,
"email_status": "verified",
"email_type": "work"
},
"meta": {}
},
"meta": {
"credits": 1,
"key_hash": "<cache key>"
}
}Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing, wrong or revoked key | Re-copy the key from app.leadocean.io and re-export $LEADOCEAN_API_KEY. |
| 429 | Above your plan's rate limit per account (5 a second on Free, 200 on Agency) | Add a short sleep to the loop, or wait the Retry-After seconds. |
| 402 | The free 1,000 records are spent | Check GET /v1/account, and lower the panel to 70 people per industry. Free records do not reset. |
meta.total is 0 or null | A filter value outside the catalogue, such as industry: "Hospitals" | Resolve the exact string with /v1/enums/industry?q= and pass it unchanged. |
| 404 on enrich | We hold nothing for that person_id, or no identifier to enrich by | Not billed. Count it as lost in Step 6 and note it separately. |
| Count stops at 100,000 | meta.total is capped at 100,000 | Split the audience by employeeRange or country and add the parts. |
FAQ
What is a typical email list decay rate by industry?
Published ranges run from about 10% to 70% a year, and they are vendor estimates. SaaS, startups and recruiting sit at the high end. That tiering is Span Global Services', as cited by LeadMagic (October 2026). Use them to set a first cadence, then replace them with your own measurement.
How is decay different from bounce rate?
Decay is a list-state metric: how many records go bad over time. Bounce rate is a send-time metric: how many messages failed in one campaign. Track both, and read email_status before every send.
Does LeadOcean give me a decay rate directly?
No. LeadOcean returns the current status of each address and the mix per segment. The rate comes from comparing two readings of the same people, as in Steps 3 to 6. No refund or credit is given for bounced emails, so check the status first.
How often does the data change?
The dataset is refreshed monthly, and each record carries its own fetched_at date. A monthly snapshot is the right cadence for Step 2.
Can I run this without code?
Partly. The Exports page in the app at app.leadocean.io has a filter builder, a column picker and the record price before you start. It gives you a CSV of one segment, but the two-reading comparison still needs a script.
For cleaning steps after you measure, read how to clean an email list and cut your bounce rate. For the term itself, see email list churn. For tools that re-verify lists, see the best hygienic email list tools for B2B. Plans are on pricing, and more recipes live in the blog hub.
Measure your own list decay with 1,000 free records
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →