Clean a list in four passes: dedupe, drop addresses that should never get a send, check the rest against a stored status, and replace stale work emails. The first two passes are free and local. The third spends records.
TL;DR
- Four passes take about 30 minutes for a few thousand addresses. Most of that is the status check running at your plan's rate limit.
- Cost: one LeadOcean record per address we hold. An address we hold nothing for is a 404 and costs nothing.
- The Free plan (1,000 records for life, no card) cleans up to 1,000 addresses. Paid plans have a fair-use pace, not credits.
Prerequisites
- A LeadOcean API key in
$LEADOCEAN_API_KEY. Start free, no card. - Your list as
list.csvwith anemailcolumn. - Python 3 with
requests, andcurl. - Know your rate limit: 5 requests a second on Free, 50 on Solo, 200 on Agency (see pricing).
Step 1: Dedupe and normalize
Verification spends a record per hit, so remove repeats first. Lowercase, trim, and collapse the same address written twice.
import csv
seen, rows = set(), []
for r in csv.DictReader(open("list.csv")):
e = r["email"].strip().lower()
if e and "@" in e and e not in seen:
seen.add(e)
rows.append(e)
print(len(rows), "unique addresses")
open("step1.txt", "w").write("\n".join(rows))Do not collapse j.doe@acme.com and jane.doe@acme.com yet. They may be two real mailboxes. The status check in Step 3 settles it.
Step 2: Drop what should never get a send
Role addresses (info@, sales@) go to shared inboxes and draw complaints. Throwaway domains are never worth mailing. Drop both before you spend anything.
ROLE = {"info", "sales", "support", "admin", "contact", "hello", "office", "billing", "noreply"}
DISPOSABLE = {"mailinator.com", "guerrillamail.com", "10minutemail.com"} # extend with your own list
keep = []
for e in open("step1.txt").read().split():
local, domain = e.split("@", 1)
if local in ROLE or domain in DISPOSABLE:
continue
keep.append(e)
print(len(keep), "left")
open("step2.txt", "w").write("\n".join(keep))This catches obvious cases only. LeadOcean also labels role, disposable, spam_trap and abuse as statuses, so the next step catches the rest for addresses we hold.
Step 3: Check each address against its stored status
Enrich by email. The response carries email_status and email_type for that person. Send reveal_email true to also get the address list with a status per address.
curl -s -X POST https://api.leadocean.io/v1/people/enrich \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"email": "jane.doe@acme.com", "reveal_email": true}'Run it over the list, paced to your plan:
import os, time, json, requests
H = {"x-api-key": os.environ["LEADOCEAN_API_KEY"], "Content-Type": "application/json"}
PER_SEC = 5 # Free. Use 50 on Solo, 200 on Agency.
out = []
for e in open("step2.txt").read().split():
r = requests.post("https://api.leadocean.io/v1/people/enrich",
headers=H, json={"email": e, "reveal_email": True})
if r.status_code == 404:
out.append({"email": e, "status": "not_held"}) # free, and not a verdict
elif r.status_code == 200:
d = r.json()["data"]["contact_data"]
pid = r.json()["data"].get("meta", {}).get("source_ids", {}).get("person_id")
out.append({"email": e, "status": d.get("email_status"), "type": d.get("email_type"), "person_id": pid})
elif r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", 1)))
else:
r.raise_for_status()
time.sleep(1 / PER_SEC)
json.dump(out, open("checked.json", "w"), indent=1)A 404 means we hold nothing for that person. It does not mean the address is bad. Put those on a hold list and test them with your own verifier. If you need to retry after a 429, requeue the address; the loop above skips it.
Step 4: Sort by status
Act on the status, not on a feeling. The status values come from GET /v1/enums/email_status.
| Status | Meaning | Action |
|---|---|---|
verified | A deliverability check passed, ours or a source held to the same standard | Send |
catch_all_valid | Domain accepts anything, mailbox confirmed | Send |
catch_all | Domain accepts anything, mailbox unconfirmed | Send in small batches, watch bounces |
risky, role, derived, unknown, untested | Not safe, or never tested | Hold, test separately |
invalid, disposable, spam_trap, abuse | Do not send | Drop |
Also read email_type. A work_other address belongs to a former employer. It is the most likely to bounce or reach whoever inherited the mailbox.
send = [x for x in out if x["status"] in ("verified", "catch_all_valid")]
careful = [x for x in out if x["status"] == "catch_all"]
stale = [x for x in out if x.get("type") == "work_other"]
drop = [x for x in out if x["status"] in ("invalid", "disposable", "spam_trap", "abuse")]LeadOcean does not refund bounces. Read email_status before you send, not after.
Step 5: Re-find stale addresses instead of deleting them
A work_other address means the person moved. The contact is still worth keeping. The Step 3 loop saved each person's id from data.meta.source_ids.person_id. Pass it to the work-email endpoint.
curl -s "https://api.leadocean.io/v2/people/email/work?person_id=PERSON_ID" \
-H "x-api-key: $LEADOCEAN_API_KEY"This returns one address at the current employer's domain, or data: null. It only returns addresses that are verified, catch_all_valid or catch_all. The call costs one record, including when it finds nothing. A null means none that qualify, not that the person has no email.
What you get
This is the response shape from the API reference at api.leadocean.io/openapi.json, with placeholder values. It is not a real person.
{
"success": true,
"data": {
"contact_data": {
"has_email": true,
"email_status": "verified",
"email_type": "work",
"contact_emails": [
{
"email": "jane.doe@acme.com",
"type": "work",
"status": "verified",
"verified_batch_date": "2026-09-20"
}
]
}
},
"meta": {}
}verified_batch_date is the date of the verification run, not a timestamp for the address. Null there means the check was not ours.
You can size the mailable share of an audience for free before you buy a list to replace the dropped rows. Counts from leadocean_count_leads on 2026-10-21, for VPs in Sales & Business Development located in Canada:
| Filter | Count |
|---|---|
| No email condition | 18,800 |
emailStatus: verified, catch_all_valid, catch_all (the default) | 5,574 |
emailStatus: verified only | 1,854 |
emailStatus: invalid, risky, role, disposable, spam_trap, abuse | 1,969 |
Read the table as a filter, not a mailable count. Only about three in ten people in this audience carry an address in the default sendable set. The count call spends no records.
A search count of verified is an upper bound. The API reference says a person can read verified in a search row and untested when enriched, and the enriched answer is the correct one. Confirm on enrichment, as in Step 3.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing or wrong x-api-key header | Check $LEADOCEAN_API_KEY is exported and sent in that header |
| 402 | Out of records (Free is 1,000 for life) | Check GET /v1/usage, then move to a paid plan |
| 404 | We hold nothing for that address | Free. Put it on the hold list, not the drop list |
| 429 | Above the plan's rate limit (5 a second on Free, 200 on Agency) | Honour the Retry-After header and slow the loop |
| Empty results on a filtered search | Bad enum value such as Vp or sales | Use exact strings from GET /v1/enums or leadocean_list_enum_values |
400 export_limit_rows | Export over 50,000 rows a request | Split the export. The daily ceiling is 500,000 rows |
FAQ
How often should I clean an email list?
Before every large send, and monthly if you mail every week. The LeadOcean dataset is refreshed monthly, and each record carries its own fetched_at date. A list you cleaned in March is not clean in October.
What bounce rate is acceptable?
Clay's guide says inbox placement starts to slip near 2% and most ESPs throttle or suspend past 5% (source, October 2026). Treat those as one vendor's figures, and watch your own sender dashboard. For the mechanics, read email bounce rate.
Does a 404 mean the address is invalid?
No. It means LeadOcean holds nothing for that person. Test those addresses with a separate verifier before you send.
Can I do this for a donor or member list?
Yes, the same four passes apply, but personal addresses are the exception. LeadOcean does not verify consumer domains, so a verified status on a personal address means a supplier asserted it. See email verification for nonprofits.
How fast does a list decay?
It depends on your industry and the age of the list. The numbers are in email list decay rates by industry. More guides are in the blog.
Check your list against real email statuses. Start free.
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →