A donor list is mostly personal addresses, and LeadOcean does not verify personal addresses. It verifies business addresses, so it fits the work emails in your file: board members, corporate givers, foundation contacts and major-gift prospects. This guide splits the file by address type, checks the work half through the API, and sends the personal half to a dedicated verifier.
The data in the examples is placeholder data. Swap in your own export.
TL;DR
- Time: about 45 minutes for a 50-row pilot, then minutes per file once the script exists.
- Records: 0 to 50 LeadOcean records. Each person we hold costs one record. A person we hold nothing for is a 404 and costs nothing.
- Rule: act on
verified,catch_all_validand the do-not-send statuses. Senduntested,unknownand every personal address to a verifier before you mail them.
Prerequisites
- A LeadOcean key in
$LEADOCEAN_API_KEY. Sign up at app.leadocean.io, no card needed. The free key has both scopes, search and enrich. - A CSV export from your donor CRM with one
emailcolumn. Addlast_gift_dateif you have it. - Python 3 with
requests, andcurl. - A dedicated email verifier for the personal half. See best email verification tools.
Step 1: Split the file by address type
Sort the file before you spend anything. Consumer domains go one way, business domains the other.
import csv, re
CONSUMER = {"gmail.com", "yahoo.com", "outlook.com", "hotmail.com",
"icloud.com", "aol.com", "proton.me", "live.com"}
ROLE = {"info", "admin", "office", "contact", "sales", "support", "noreply"}
SYNTAX = re.compile(r"^[^@\s]+@[^@\s]+\.[A-Za-z]{2,}$")
work, personal, reject, seen = [], [], [], set()
for row in csv.DictReader(open("donors.csv", newline="")):
email = row["email"].strip().lower()
local, _, domain = email.partition("@")
if not SYNTAX.match(email) or email in seen or local in ROLE:
reject.append(row); continue
seen.add(email)
(personal if domain in CONSUMER else work).append(row)
for name, rows in (("work.csv", work), ("personal.csv", personal), ("reject.csv", reject)):
with open(name, "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=rows[0].keys() if rows else ["email"])
w.writeheader(); w.writerows(rows)
print(len(work), len(personal), len(reject))This step is free and local. Syntax errors, duplicates and shared mailboxes such as info@ leave the file here. Add your own domains to CONSUMER for regional webmail.
Step 2: Read the status LeadOcean holds for each work address
POST /v1/people/enrich takes an email and returns the person we hold for it. Send reveal_email so the answer lists every address with its own status.
import csv, os, requests
H = {"x-api-key": os.environ["LEADOCEAN_API_KEY"], "Content-Type": "application/json"}
out = []
for row in list(csv.DictReader(open("work.csv", newline="")))[:50]:
addr = row["email"].strip().lower()
r = requests.post("https://api.leadocean.io/v1/people/enrich", headers=H,
json={"email": addr, "reveal_email": True})
if r.status_code == 404:
out.append({**row, "email_status": "not_held"}); continue
r.raise_for_status()
emails = r.json()["data"]["contact_data"].get("contact_emails") or []
hit = next((e for e in emails if (e.get("email") or "").lower() == addr), None)
out.append({**row, "email_status": hit["status"] if hit else "other_address"})
with open("work_checked.csv", "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=out[0].keys()); w.writeheader(); w.writerows(out)Match on the address, not the person. The top-level email_status describes the person's best address, which may not be the one in your file. A 404 says LeadOcean holds nothing for that person. It does not say the address is bad.
Step 3: Route every row by status
Each status maps to one action. Decide the action once, in code, so every campaign uses it.
SEND = {"verified", "catch_all_valid"}
HOLD = {"catch_all", "risky", "derived"}
VERIFY = {"untested", "unknown", "not_held", "other_address"}
SUPPRESS = {"invalid", "spam_trap", "abuse", "disposable", "role", "none"}
def action(status):
for name, group in (("send", SEND), ("hold", HOLD), ("verify", VERIFY), ("suppress", SUPPRESS)):
if status in group: return name
return "verify"catch_all_valid is a mailbox on a catch-all domain whose existence LeadOcean confirmed. catch_all means the domain accepts any address, so nobody can confirm the mailbox. Mail it in a small batch, or hold it. risky, role and derived need care. derived is built from the company's address pattern and never tested.
Never mail spam_trap or abuse. LeadOcean gives no refund or credit-back for bounced emails, so read email_status before you send.
Step 4: Send the rest to a dedicated verifier
Every personal address, and every work address marked verify, goes to a verifier. LeadOcean says it does not verify consumer domains at all, so a verified on a personal address only means a supplier asserted it. Treat it as a lead to check.
import csv
to_verify = [r for r in csv.DictReader(open("work_checked.csv", newline=""))
if action(r["email_status"]) == "verify"]
to_verify += list(csv.DictReader(open("personal.csv", newline="")))
with open("to_verify.csv", "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=["email"], extrasaction="ignore")
w.writeheader(); w.writerows(to_verify)
print(len(to_verify), "rows for the verifier")Upload to_verify.csv to the verifier you chose. Large consumer providers often return unknown. Keep those rows on hold rather than mailing them as valid. Our guide to verifying a list in bulk covers how to read the results.
Step 5: Send in stages and watch bounces
Mail the safe group first, then widen. A staged plan limits the damage if a segment is worse than the data said.
stage_1:
audience: work_checked.csv where action == send
size: all
stage_2:
audience: personal.csv rows the verifier marked valid
size: 500 rows
gate: read hard bounces before stage 3
stage_3:
audience: catch_all and unknown rows, plus donors with a gift in the last 12 months
size: 200 rows a day
stop_if: hard bounces climb or complaints appear
suppress_forever: invalid, spam_trap, abuse, disposable, rolePut every hard bounce on your suppression list the same day. For the reasoning, read how to clean an email list and cut your bounce rate.
What you get
The status values come from the free enum endpoint. We read them on 2026-10-21 through LeadOcean's MCP leadocean_list_enum_values tool, which spends no records.
verified Deliverable
catch_all_valid Catch-all, mailbox confirmed
catch_all Catch-all domain
risky Risky
unknown Unknown
untested Not yet checked
invalid Undeliverable
role Role address
disposable Disposable address
spam_trap Spam trap
abuse Abuse / complainer
derived Derived, not verified
none No emailStep 2 answers with the shape below. This is the response shape from the API reference, with placeholder values.
{
"success": true,
"data": {
"contact_data": {
"has_email": true,
"email_status": "verified",
"email_type": "work",
"contact_emails": [
{"email": "jane.doe@acme.org", "type": "work", "status": "verified", "verified_batch_date": "2026-09-20"}
]
}
}
}untested means nobody has checked the address yet and it may be fine. It is the largest group. unknown means it was checked and the result was inconclusive.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | The header is missing or wrong. | Send x-api-key: $LEADOCEAN_API_KEY. |
| 402 | Fewer records are left than the call needs. | Check GET /v1/usage. On Free the 1,000 are one-off. |
| 404 on enrich | LeadOcean holds nothing for that person. | Route the row to the verifier. It costs nothing and is not a verdict. |
| 429 | Above your plan's rate limit per account (5 a second on Free, 50 on Solo, 200 on Agency). | Honour Retry-After and pace the loop. |
| Zero rows from a search | A filter value is outside the list, such as Canada for CA. | Read the exact values from GET /v1/enums. |
FAQ
Can LeadOcean verify any email address?
No. It returns the status it holds for people in its data, and it does not verify consumer domains. For a gmail.com donor, use a dedicated verifier.
Can LeadOcean help find corporate donors?
Yes. Search companies of type Nonprofit or their funders by title and size, and count first for free. Add ?count=true and limit 1, then read meta.total.
curl -X POST "https://api.leadocean.io/v1/people/search?count=true" \
-H "x-api-key: $LEADOCEAN_API_KEY" -H "Content-Type: application/json" \
-d '{"title":["Foundation Director"],"country":["US"],"emailType":["work"],"emailStatus":["verified"],"limit":1}'How often should I clean a donor list?
Before each large appeal, and after any list import. Read email verification explained for what a check does and does not prove.
How many records does this cost?
One record per person LeadOcean holds, so at most 50 for the pilot. Paid plans start at Solo, $97 a month, and Agency is $497 a month for teams. They have a fair-use pace, not credits, and no per-record price. See pricing.
Where do I find more guides?
The blog hub lists the rest.
Check your donor list's work emails with a free LeadOcean key
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →