Pre-Black Friday: 20% off, for as long as you stayEARLYBFon Solo and AgencyEnds Nov 28Start free

How-to guide

How to Clean a Donor Email List (Email Verification for Nonprofits)

Split your donor file by address type, read the email status LeadOcean holds for work addresses, and route the rest to a verifier before the next appeal.

Get your free API key →Free to start. No credit card. 1,000 records to spend whenever you like.

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_valid and the do-not-send statuses. Send untested, unknown and 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 email column. Add last_gift_date if you have it.
  • Python 3 with requests, and curl.
  • 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.

python
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.

python
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.

python
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.

python
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.

yaml
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, role

Put 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.

text
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 email

Step 2 answers with the shape below. This is the response shape from the API reference, with placeholder values.

json
{
  "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

ErrorCauseFix
401The header is missing or wrong.Send x-api-key: $LEADOCEAN_API_KEY.
402Fewer records are left than the call needs.Check GET /v1/usage. On Free the 1,000 are one-off.
404 on enrichLeadOcean holds nothing for that person.Route the row to the verifier. It costs nothing and is not a verdict.
429Above 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 searchA 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.

bash
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 →