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

How-to guide

How to Clean an Email List and Cut Your Bounce Rate (With the LeadOcean API)

Four passes, one script, real requests. Dedupe, drop the junk, check each address against its stored status, and re-find the people who changed jobs.

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

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.csv with an email column.
  • Python 3 with requests, and curl.
  • 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.

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

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

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

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

StatusMeaningAction
verifiedA deliverability check passed, ours or a source held to the same standardSend
catch_all_validDomain accepts anything, mailbox confirmedSend
catch_allDomain accepts anything, mailbox unconfirmedSend in small batches, watch bounces
risky, role, derived, unknown, untestedNot safe, or never testedHold, test separately
invalid, disposable, spam_trap, abuseDo not sendDrop

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.

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

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

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

FilterCount
No email condition18,800
emailStatus: verified, catch_all_valid, catch_all (the default)5,574
emailStatus: verified only1,854
emailStatus: invalid, risky, role, disposable, spam_trap, abuse1,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

ErrorCauseFix
401Missing or wrong x-api-key headerCheck $LEADOCEAN_API_KEY is exported and sent in that header
402Out of records (Free is 1,000 for life)Check GET /v1/usage, then move to a paid plan
404We hold nothing for that addressFree. Put it on the hold list, not the drop list
429Above 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 searchBad enum value such as Vp or salesUse exact strings from GET /v1/enums or leadocean_list_enum_values
400 export_limit_rowsExport over 50,000 rows a requestSplit 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 →