Email hygiene has four jobs. Each one maps to a step below.
- Validity. Drop addresses that cannot receive mail:
invalid,disposable,spam_trapandabuse. Sending to these hurts the sender domain and gains nothing. - Certainty. Separate addresses someone tested from addresses nobody tested.
verifiedmeans the address accepted a real probe.untestedmeans no one has looked yet. - Hygiene of the list itself. Remove duplicates, shared mailboxes such as info@, and anyone who bounced or unsubscribed before.
- Freshness. People change jobs. Check a sample again right before the send.
Most guides stop at "use a verifier". The part a technical buyer needs is a rule set that runs the same way on every list. The steps below build one in about 20 minutes.
TL;DR
- Email hygiene is a send policy applied to every address: which statuses go out, which are held, which are dropped. Do it before the campaign, not after the bounces.
- It takes about 20 minutes. The example spends about 105 records: 100 for the export and 5 for the pre-send check. The export step needs Solo ($97 a month) or above.
- Counting by email status is free. You see the size of every bucket before you spend a record.
Prerequisites
- A LeadOcean account from app.leadocean.io. The free plan has no card and 1,000 records, one-off.
- A key in your shell as
$LEADOCEAN_API_KEY. The export step reveals emails, so the key needs theenrichscope. Free keys get both scopes. curl,jqand Python 3.
export LEADOCEAN_API_KEY="paste-your-key-here"Step 1: Count the list by email status
Start with a free count per status. A search with count=true as a query parameter and limit at 1 returns meta.total and no rows.
The example audience is VPs in sales at companies in Canada. Loop over the status groups you care about.
for s in verified catch_all untested risky role derived invalid disposable spam_trap abuse; do
n=$(curl -s "https://api.leadocean.io/v1/people/search?count=true" \
-H "x-api-key: $LEADOCEAN_API_KEY" -H "content-type: application/json" \
-d "{
\"jobLevel\": [\"VP\"],
\"jobFunction\": [\"Sales & Business Development\"],
\"country\": [\"CA\"],
\"emailStatus\": [\"$s\"],
\"limit\": 1
}" | jq '.meta.total')
echo "$s $n"
doneEach call is free and meta.total is capped at 100,000. The same count works in chat through the MCP server with leadocean_count_leads.
Use leadocean_count_leads with jobLevel ["VP"], jobFunction ["Sales & Business Development"],
country ["CA"] and emailStatus ["verified"]. Then repeat with ["catch_all"] and ["untested"].
Do not call any tool that spends records.Step 2: Write the send policy
Decide what each status means before you pull anything. The status values and their meaning come from the LeadOcean email_status list.
SEND verified our own verifier sent to the address and it accepted
HOLD catch_all the domain accepts everything, so delivery cannot be confirmed
HOLD untested nobody has checked this address yet, often fine
CARE risky, role role is a shared mailbox such as info@, send only to a tested sample
CARE derived built from the company's address pattern and never tested
DROP invalid, disposable, spam_trap, abuseWhy hold catch_all at all? A catch-all domain accepts mail for any address, so no probe can say whether the mailbox exists. That is a limit of the domain, not of the data. The same logic makes untested a hold, not a drop: the address may be fine, it just has no check behind it.
For a first campaign, send to verified only. Add catch_all later, in small batches, and watch bounces.
Treat a search count of verified as an upper bound. People search does not carry per-address verification provenance, while enrichment applies the narrow rule per address. A person can show verified in search and untested when enriched. The enriched answer is the correct one.
Step 3: Export only the sendable rows
Pull the policy into the request. The filter does the first pass, so you never pay for rows you would drop.
Bulk CSV export is not on the Free plan. It is included from Solo ($97 a month) up. On Free, page the search 100 rows at a time instead. An export takes up to 50,000 rows a request and 500,000 a day. Each row costs one record. This example caps limit at 100.
curl -s https://api.leadocean.io/v1/exports \
-H "x-api-key: $LEADOCEAN_API_KEY" -H "content-type: application/json" \
-d '{
"name": "Canada sales VPs, verified only",
"filters": {
"jobLevel": ["VP"],
"jobFunction": ["Sales & Business Development"],
"country": ["CA"],
"emailStatus": ["verified"]
},
"limit": 100,
"preset": "full"
}' | tee export.json | jq '.data | {id, status, requested, perRow, remaining, pollAfter}'The job runs in the background. Poll every pollAfter seconds, then download when the status is done.
ID=$(jq -r '.data.id' export.json)
curl -s https://api.leadocean.io/v1/exports/$ID -H "x-api-key: $LEADOCEAN_API_KEY" | jq '.data.status'
curl -s https://api.leadocean.io/v1/exports/$ID/download -H "x-api-key: $LEADOCEAN_API_KEY" -o list.csvThe numbered columns come back as email_1_address, email_1_status, email_2_address and so on. The LeadOcean app has the same export on its Exports page, with a column picker and the record price before you start.
Step 4: Dedupe and suppress in code
The vendor cannot know your history. Remove duplicates, role mailboxes and everyone on your own suppression list. Keep this step in code so it runs on every list.
Put one address per line in suppress.txt: past bounces, unsubscribes, customers.
import csv, sys
ALLOWED = {"verified"}
ROLE = {"info", "sales", "support", "admin", "contact", "hello", "office", "billing"}
suppress = {l.strip().lower() for l in open("suppress.txt") if l.strip()}
seen, kept, dropped = set(), [], {"status": 0, "role": 0, "dupe": 0, "suppressed": 0}
for row in csv.DictReader(open("list.csv")):
email = (row.get("email_1_address") or "").strip().lower()
status = row.get("email_1_status") or ""
if status not in ALLOWED: dropped["status"] += 1; continue
if email.split("@")[0] in ROLE: dropped["role"] += 1; continue
if email in seen: dropped["dupe"] += 1; continue
if email in suppress: dropped["suppressed"] += 1; continue
seen.add(email); kept.append(row)
if kept:
with open("clean.csv", "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=kept[0].keys())
w.writeheader(); w.writerows(kept)
print(len(kept), "kept;", dropped, file=sys.stderr)The role list is a starting point. Edit it for your market. A role mailbox such as info@ reaches a shared inbox, not the person you picked, and LeadOcean already marks those it holds as role. The status check repeats the export filter on purpose, so a CSV from any other source gets the same treatment.
Run it with python3 clean.py. It prints how many rows survived and why the rest went. Log those numbers: a rising status count means your filters are loose.
Step 5: Re-check a sample before you send
The dataset is refreshed monthly, and each record carries its own fetched_at date. A list pulled three weeks ago is not today's list. Enrich a small sample by person_id and read the status again.
curl -s https://api.leadocean.io/v1/people/enrich \
-H "x-api-key: $LEADOCEAN_API_KEY" -H "content-type: application/json" \
-d '{"person_id": "<person id from the export>", "reveal_email": true}' | jq '.data'Enrich takes exactly one of linkedin_url, email, phone or person_id. A person we hold nothing for is a 404 and costs nothing. A hit costs one record, so 5 people spend 5.
Do this before every campaign. A list older than a month needs the Step 1 counts and this sample again, and the counts cost nothing.
If any sampled address has dropped out of verified, re-run Step 3 for the whole list. After the send, add every hard bounce and unsubscribe to suppress.txt.
What you get
On 21 October 2026 we ran the Step 1 counts through the free leadocean_count_leads tool on the example audience (VPs, Sales & Business Development, Canada). None of the calls spent a record.
| Email status filter | Count |
|---|---|
| default (verified, catch_all_valid, catch_all) | 5,574 |
| verified | 1,854 |
| catch_all (also matches catch_all_valid) | 3,720 |
| untested | 7,283 |
| risky, role, derived | 557 |
| invalid, disposable, spam_trap, abuse | 1,416 |
The default count is the mailable audience, not everyone matching the other filters. The other rows are counts by status, not mailable totals. Search counts of verified are an upper bound, as Step 2 says. Your numbers will differ as the data refreshes.
The export request returns HTTP 202 and a job. This is the response shape from the API reference, with placeholder values.
{
"success": true,
"data": {
"id": "<export id>",
"name": "Canada sales VPs, verified only",
"status": "queued",
"requested": 100,
"perRow": 1,
"remaining": "<records left after this reservation>",
"pollAfter": 10,
"file": null
}
}The finished download is a plain .csv, one row per person. Rows never written are released. The run costs about 105 records: 100 for the export and 5 for the pre-send check.
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), or an export ceiling reached | Wait the Retry-After seconds. Send fewer, fuller calls. |
| 402 | The 1,000 free records are spent, or the export limit is above what is left | Lower limit to what GET /v1/account shows, or move to Solo ($97 a month) or Agency ($497 a month) on pricing. |
| 400, or a count of 0 | A filter value outside the catalogue, such as jobLevel: "Vice President" | Resolve it with leadocean_list_enum_values and pass the exact string. |
400 export_limit_rows | limit is above 50,000 rows for one export | Split the audience by country or headcount band and run several exports. |
| 403 | The key lacks the enrich scope, which contact columns need | Use a key with the scope. |
FAQ
What is email hygiene?
Email hygiene is keeping a send list clean: only deliverable addresses, no duplicates, no role mailboxes, no one who opted out. It is a rule set you apply on every list, not a one-off cleanup.
Is catch_all safe to send to?
It cannot be confirmed either way, because the domain accepts every address. Hold it for a later batch, send small, and watch bounces before you scale. Start with verified.
Does LeadOcean refund bounced emails?
No. There is no refund or credit-back for bounces. Read email_status before you send, and use the policy in Step 2.
How is this different from a standalone email verifier?
A verifier checks addresses you already hold. Here the status arrives with the record and you filter on it before you pay. For tools that check your own list, see email verifier free software.
Where does this fit in a cold email program?
Hygiene comes before copy and timing. A clean list protects the sender domain, which is what the cold email response rate depends on. For sourcing, see email marketing databases. Plans are on pricing.
More guides live in the blog hub.
Clean your next cold email list before you send it
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →