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

How-to guide

How to Fix Dirty CRM Data With Enrichment (9 Tools, One Recipe)

Measure the gaps in a CRM export, fill them from one source, then pay a second tool only for the rows that come back empty.

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

Fix dirty CRM data in four moves: measure the gaps, fill them from one source, send only the misses to a paid tool, then load the result back. Nine tools can do the filling. You need one of them first and the rest only for leftovers.

This page gives you the order, a map of the nine, and two scripts you can run today. The first hop is LeadOcean because a contact it holds nothing for is a 404 and costs no record (LeadOcean docs, September 2026). That makes the first pass cheap to run on a messy list.

TL;DR

  • Audit the export locally, enrich every unique contact once, then route only the misses to a paid tool. Nothing you pay for runs on a row that was already filled.
  • It takes about 40 minutes for 500 contacts. The audit and sizing spend 0 LeadOcean records.
  • The enrich pass spends up to 500 records, one per contact LeadOcean holds. The Free plan has 1,000, one-off, with no card.

Prerequisites

  • A CRM that exports contacts to CSV with these columns: email, linkedin_url, job_title, company_domain. Rename yours to match.
  • A LeadOcean API key from app.leadocean.io. Free keys get both the search and enrich scopes.
  • Python 3 (standard library only) and curl. Put the key in your shell, never in a script.
bash
export LEADOCEAN_API_KEY="paste-your-key-here"

Step 1: Measure the dirt (free, local)

Count what is wrong before you spend anything. The audit shows blank fields, duplicate emails and free-mail addresses, which are the three things that decide how much a clean-up can fix.

python
import csv, sys
FREEMAIL = {"gmail.com", "yahoo.com", "outlook.com", "hotmail.com"}
rows = list(csv.DictReader(open(sys.argv[1], newline="")))
blank = lambda f: sum(1 for r in rows if not r.get(f, "").strip())
emails = [r["email"].strip().lower() for r in rows if r["email"].strip()]
free = sum(1 for e in emails if e.split("@")[-1] in FREEMAIL)
print(f"rows            {len(rows)}")
print(f"no email        {blank('email')}")
print(f"no linkedin     {blank('linkedin_url')}")
print(f"no job title    {blank('job_title')}")
print(f"no domain       {blank('company_domain')}")
print(f"duplicate email {len(emails) - len(set(emails))}")
print(f"free-mail email {free}")

Run it with python3 audit.py crm_contacts.csv. A high duplicate count means you fix the CRM merge rules first. A high free-mail count means enrichment by email will miss more, because personal addresses rarely map to a work role.

Step 2: Match each gap to a tool

Pick the tool by the gap, not by brand. Nine tools cover the usual gaps, and each one fills a different part of the problem.

ToolUse it to fillEntry price (monthly list, billed yearly after)Source
LeadOceanEmail status, current role and company domain by email or LinkedIn URL. Replacement contacts by domain.Free: 1,000 records, one-off. Solo $97 a month, Agency $497 a month, fair-use pace, no credits.Pricing, October 2026
ApolloContact and account fields inside a per-seat platformBasic $65 a seat a month for 2,500 credits; $49 billed yearlyApollo pricing, September 2026
ClayA table that chains providers and runs each one only when a cell is emptyLaunch $185 a month; $167 billed yearlyClay pricing, September 2026
ZoomInfo (GTM AI)Company and contact records bought as data creditsPay as you go from $20; $0.45 a credit at 100 creditsGTM AI pricing, September 2026
HunterWork emails by name and domain, plus verificationStarter $49 a month for 2,000 credits; $34 billed yearlyHunter pricing, September 2026
LushaPhone numbers and emails one contact at a timeStarter $49.90 a month for 400 credits; $37.45 billed yearlyLusha pricing, September 2026
CognismMobile numbers with on-demand verificationNo public priceCognism pricing, September 2026
People Data LabsRaw person and company records through an APIPro $98 a month for 350 credits; $78 a month billed yearlyPDL pricing, September 2026
BetterContactOne request across 20+ providers, charged only on valid resultsStarter $15 a month for 200 credits; about $12.50 billed yearlyBetterContact pricing, September 2026

Here is the order we recommend. Use LeadOcean for email status, role and domain. Use a phone-focused tool only for the rows that still lack a number. Phones are the costly gap: Lusha charges 5 credits for a phone and Apollo charges 8 (Lusha docs, September 2026, Apollo docs, September 2026).

Write the routing down before you build it. This config is the whole plan.

text
gap                      first hop            fallback (only on a miss)
email status, role       LeadOcean enrich     Hunter or BetterContact
company domain           LeadOcean enrich     People Data Labs
replacement contact      LeadOcean search     Apollo or ZoomInfo (GTM AI)
mobile phone             none                 Lusha or Cognism
chain the fallbacks      Clay "Only run if"   see Step 5

Step 3: Enrich every contact once

Enrich by email with POST /v1/people/enrich, or by LinkedIn URL when the email is blank. Send exactly one identifier per call. A 404 means LeadOcean holds nothing, costs no record, and goes to misses.csv for Step 5.

python
import csv, json, os, sys, urllib.request, urllib.error
KEY = os.environ["LEADOCEAN_API_KEY"]
URL = "https://api.leadocean.io/v1/people/enrich"

def enrich(body):
    req = urllib.request.Request(URL, json.dumps(body).encode(),
        {"x-api-key": KEY, "content-type": "application/json"})
    try:
        with urllib.request.urlopen(req) as r:
            return r.status, json.load(r)
    except urllib.error.HTTPError as e:
        return e.code, None

seen, out, miss = set(), [], []
for r in csv.DictReader(open("crm_contacts.csv", newline="")):
    email = r["email"].strip().lower()
    if email and email in seen:
        continue
    seen.add(email)
    # exactly one identifier per call: email first, LinkedIn URL second
    body = {"email": email} if email else {"linkedin_url": r["linkedin_url"].strip()}
    if not any(body.values()):
        miss.append(r); continue
    code, resp = enrich({**body, "reveal_email": False})
    if code == 200:
        c = resp["data"]["contact_data"]
        role = (c.get("contact_current_experiences") or [{}])[0]
        out.append({"crm_email": email, "email_status": c.get("email_status"),
            "email_type": c.get("email_type"), "job_title": role.get("job_title"),
            "company_name": role.get("company_name"), "company_domain": role.get("company_domain")})
    elif code == 404:
        miss.append(r)          # we hold nothing: free, send to a fallback
    else:
        sys.exit(f"stopped at {body} with HTTP {code}")

def write(path, rows):
    if rows:
        with open(path, "w", newline="") as f:
            w = csv.DictWriter(f, fieldnames=list(rows[0])); w.writeheader(); w.writerows(rows)
write("enriched.csv", out); write("misses.csv", miss)
print(f"filled {len(out)}, missed {len(miss)}")

The script reads crm_contacts.csv and writes two files. enriched.csv has one line per hit. misses.csv has the rows to route to a paid tool. It runs one request at a time. The rate limit is per account and per plan: 5 requests a second on Free, 200 on Agency (LeadOcean docs, October 2026). On Free, add a 0.2-second sleep for long files.

Two things to know. The call returns the verdict and the role, not new addresses, because the script sends reveal_email: false. Left out, it defaults to true on an email lookup. And a stop on 402 or 429 prints the row it stopped at, so you can resume.

Step 4: Size and pull replacements for contacts who left

An email_type of work_other means the address belongs to a former employer. That contact has likely moved, which leaves a gap at the account. Size the replacements first. This is free.

bash
curl -s "https://api.leadocean.io/v1/people/search?count=true" \
  -H "x-api-key: $LEADOCEAN_API_KEY" -H "content-type: application/json" \
  -d '{"domain":["acme.com"],"jobLevel":["VP","Director"],
       "jobFunction":["Operations"],"limit":1}' | jq '.meta'

count=true goes in the query string, not the body. The response carries meta.total and no rows. Search never shows an address. It shows meta.source_ids.person_id, which you pass back to enrich when you want the contact.

You can size from an AI client too. The MCP server at https://api.leadocean.io/mcp has leadocean_count_leads, which is free. Resolve exact filter strings first, because enum values are case-sensitive.

text
Use leadocean_list_enum_values with name job_level.
Then use leadocean_count_leads with jobLevel ["Director"], jobFunction ["Operations"],
country ["GB"], facets false. Repeat with emailStatus ["verified"]. Say what each total counted.

For hundreds of accounts at once, pull the rows as a file. Bulk CSV export is included on Solo and above, not on Free. POST /v1/exports takes up to 50,000 rows a request and 500,000 a day. The search preset returns only what a search row carries, and every row costs one record.

bash
curl -s -X POST https://api.leadocean.io/v1/exports \
  -H "x-api-key: $LEADOCEAN_API_KEY" -H "content-type: application/json" \
  -d '{"name":"replacement-contacts","preset":"search","limit":500,
       "filters":{"domain":["acme.com","globex.example"],"jobLevel":["VP","Director"]}}'

The same job runs from the Exports page in app.leadocean.io: filter builder, column picker, price preview and a CSV download.

Step 5: Pay a second tool only for the misses

misses.csv is the only file a paid tool should see. Upload it to Clay, Hunter, BetterContact or whichever tool the routing in Step 2 names. The rest of the list was already filled, so it costs you nothing more.

If you use Clay, chain the fallback inside the table. Clay lets you set a run condition on each enrichment column. Set "Only run if" to fire when the LeadOcean email column is empty (Clay docs, September 2026). The LeadOcean and Clay recipe shows the HTTP API column setup. Clay lists that column on Growth and Enterprise, not Launch (Clay pricing, September 2026).

text
Column:        Hunter email finder
Only run if:   {{LeadOcean email status}} is empty

Test on five rows before the full run. Check that filled rows skip the paid column and that empty rows trigger it.

Step 6: Load it back and re-run monthly

Load enriched.csv into your CRM with its import-and-update-by-email feature. Write email_status into a custom field so you can filter on it. Treat verified and catch_all_valid as safe to send. Send catch_all in a small, separate batch, and read the rest before any send.

LeadOcean gives no refund or credit for bounced emails, so the status is your only guard. The dataset refreshes monthly, and each record carries its own fetched_at date. A monthly run keeps the CRM in step. This crontab line runs at 06:00 on the first of the month. Export a fresh crm_contacts.csv before it fires.

bash
0 6 1 * * cd ~/crm-cleanup && python3 enrich.py >> cleanup.log 2>&1

What you get

Step 1 prints the gap counts. This is the real output of audit.py run on 1 October 2026 against a six-row sample file of placeholder contacts, not a real CRM.

text
rows            6
no email        1
no linkedin     4
no job title    3
no domain       3
duplicate email 1
free-mail email 1

Step 3 writes enriched.csv. This is the response shape from the API reference, flattened by the script, with placeholder values.

text
crm_email,email_status,email_type,job_title,company_name,company_domain
jane.doe@acme.com,verified,work,Head of Sales,Acme,acme.com

Step 4 sizes an audience for free. These are real results from the LeadOcean MCP server on 1 October 2026 for Directors in Operations located in Great Britain. Credits spent: 0.

text
leadocean_list_enum_values  name job_level
  values: C-Team, Director, Manager, Other, Staff, VP

leadocean_count_leads  jobLevel ["Director"], jobFunction ["Operations"], country ["GB"]
  default mailable (verified, catch_all_valid, catch_all):  24,621
leadocean_count_leads  ... plus emailStatus ["verified"]
  verified only:                                              9,977

The first total is the mailable audience, not everyone who matches. The second is verified only. Read them as a range. A verified count from search is an upper bound. The enrich call in Step 3 gives the answer for each person.

Troubleshooting

ErrorCauseFix
401Missing, wrong or revoked keyCopy the key again from app.leadocean.io and re-export $LEADOCEAN_API_KEY.
402The Free plan's 1,000 one-off records are spentRun a shorter list, or move to Solo or Agency on pricing. Free records do not reset.
429Above your plan's rate on one key (5 a second on Free, 200 on Agency)Slow the loop and wait the Retry-After seconds.
400 on enrichTwo identifiers in one callSend exactly one of linkedin_url, email, phone or person_id.
Empty search resultsA filter value outside the catalogue, such as director for DirectorList the exact strings with GET /v1/enums/job_level, then copy one.
400 or 429 on exportOver 50,000 rows in one export, or over 500,000 rows in a daySplit the job into exports of 50,000 rows or fewer, and wait for the daily reset.

FAQ

How many records does this clean-up cost?

One per contact LeadOcean holds. A miss is a 404 and costs nothing. A 500-contact export spends at most 500, and the Free plan has 1,000. Solo at $97 and Agency at $497 a month have no per-record price, only a fair-use pace. See pricing.

Which of the nine tools should I start with?

Start with the one that charges nothing for a miss, then add a second tool only for the leftovers. For email status, role and domain, that is LeadOcean. For mobile numbers, pick a phone-focused tool from the Step 2 table and run it on the misses only.

Can I match on name and company instead of email?

Not directly. There is no name plus domain lookup. Search by domain first, then enrich by person_id. Step 4 shows the search half.

Does a verified status guarantee no bounces?

No. Read email_status before you send, because LeadOcean gives no refund for bounces. catch_all means the domain accepts any address, so it cannot be confirmed either way.

Where do I compare these tools in more depth?

Read the 8 enrichment tools that fix a messy CRM and the free and paid CRM enrichment tools to try. For what enrichment adds to a record, see what CRM enrichment adds to your sales data. The CRM hygiene recipe covers dedupe and moved-job buckets. More guides are in the blog hub.

Fix your CRM data with 1,000 free records

Free to start. No credit card. 1,000 records to spend whenever you like.

Get your free API key →