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

How-to guide

How to Enrich and Clean CRM Records (With the LeadOcean API)

Export 500 contacts, enrich each by email, flag duplicates, bad addresses and job changes, then import only the changes.

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

TL;DR

  • Export your contacts as CSV, enrich each one by work email with POST /v1/people/enrich, then diff the answer against your CRM. Nothing is overwritten until you review it.
  • It takes about 45 minutes for 500 contacts. It spends up to 800 records: 500 person lookups and up to 300 company lookups. A lookup that finds nobody is a 404 and costs nothing.
  • The free plan (1,000 records, one-off, no card) covers the whole run. Dedupe uses meta.key_hash, so it needs no extra call.

Prerequisites

  • A LeadOcean API key from app.leadocean.io. The key needs the enrich scope.
  • The key in your shell as $LEADOCEAN_API_KEY.
  • A CSV export of your CRM contacts with these columns: id, email, first_name, last_name, title, company, domain. Any CRM that exports and imports CSV works.
  • Python 3 with requests installed, and curl for the checks.
bash
export LEADOCEAN_API_KEY="paste-your-key-here"
pip install requests

Step 1: Profile the mess before you spend anything

Count what is wrong using only your own file. This tells you how many records the run can spend. A contact with no email cannot be enriched by this recipe, because enrich takes one of linkedin_url, email, phone or person_id. There is no name plus company lookup.

python
import csv, collections, re

rows = list(csv.DictReader(open("crm_contacts.csv", newline="")))
FREE = {"gmail.com", "yahoo.com", "outlook.com", "hotmail.com", "icloud.com"}
ROLE = re.compile(r"^(info|sales|support|admin|hello|contact|team)@", re.I)

emails = [r["email"].strip().lower() for r in rows if r["email"].strip()]
dupes = [e for e, n in collections.Counter(emails).items() if n > 1]
print("rows:", len(rows))
print("no email:", len(rows) - len(emails))
print("exact duplicate emails:", len(dupes))
print("free-mail addresses:", sum(e.split("@")[-1] in FREE for e in emails))
print("role addresses:", sum(bool(ROLE.match(e)) for e in emails))
print("blank title:", sum(not r["title"].strip() for r in rows))
print("unique domains:", len({r["domain"].strip().lower() for r in rows if r["domain"].strip()}))

Exact duplicates are the easy half. The harder half is two rows with different emails for one person. Step 3 catches those.

Run this on a sample first. Take 500 rows, not the whole table. A sample shows you the match rate and the label mix before you commit more records, and it keeps the first review short enough to finish in one sitting.

Why enrich by email and not by name? The email is the one key most CRMs hold on nearly every contact. It also resolves to a single person, where a name and a company string can match several. LinkedIn URL is the better key when you have it, so swap it in for rows with no email.

Step 2: Check the key and your balance

GET /v1/account is free. It returns your plan, records used and remaining, and the key's scopes. Confirm the enrich scope is on the key before you loop over 500 rows.

bash
curl -s https://api.leadocean.io/v1/account \
  -H "x-api-key: $LEADOCEAN_API_KEY" | jq '.data | {plan: .account.plan, scopes: .key.scopes, remaining: .records.remaining}'

If remaining is lower than the number of rows with an email, enrich a slice first. Free records do not reset.

Step 3: Enrich every contact by email

Send one request per contact with the email and reveal_email set to true. The call costs one record when we hold the person, and the reveal adds every address and number we hold at no extra record. A person we hold nothing for is a 404 and costs nothing.

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}'

The loop below runs the same call for the whole file. It sleeps 200 ms between calls, which stays under the Free plan's 5 requests a second. It stops on a 402 and backs off on a 429.

python
import csv, json, os, time, requests

URL = "https://api.leadocean.io/v1/people/enrich"
HEAD = {"x-api-key": os.environ["LEADOCEAN_API_KEY"]}
rows = list(csv.DictReader(open("crm_contacts.csv", newline="")))

with open("enriched.jsonl", "w") as out:
    for r in rows:
        email = r["email"].strip().lower()
        if not email:
            continue
        while True:
            res = requests.post(URL, headers=HEAD, timeout=20,
                                json={"email": email, "reveal_email": True})
            if res.status_code == 429:
                time.sleep(int(res.headers.get("Retry-After", "1")))
                continue
            break
        if res.status_code == 402:
            raise SystemExit("Out of records. Stop and check GET /v1/account.")
        if res.status_code == 404:
            out.write(json.dumps({"id": r["id"], "found": False}) + "\n")
            continue
        res.raise_for_status()
        body = res.json()
        cd = body["data"].get("contact_data") or {}
        role = (cd.get("contact_current_experiences") or [{}])[0]
        mine = [e for e in cd.get("contact_emails") or [] if (e.get("email") or "").lower() == email]
        out.write(json.dumps({
            "id": r["id"], "found": True,
            "key_hash": body["meta"].get("key_hash"),
            "email_status": (mine[0].get("status") if mine else cd.get("email_status")),
            "email_type": (mine[0].get("type") if mine else cd.get("email_type")),
            "title": role.get("job_title"),
            "company_name": role.get("company_name"),
            "company_domain": role.get("company_domain"),
        }) + "\n")
        time.sleep(0.2)

Step 4: Diff the answer against the CRM

Do not write the enrichment straight back. Compare it, label each difference, and review the labels. Four checks matter.

  • Duplicate: two CRM ids with the same key_hash are one person. The docs say the same person reached by any door carries the same value.
  • Bad address: read email_status. invalid, spam_trap, abuse and disposable are drops. risky, role and derived are your call.
  • Possible job change: the current employer's domain differs from the CRM domain. An email_type of work_other points the same way, because it means a former employer.
  • Fill: the CRM title is blank and we hold one.
python
import csv, json, collections

crm = {r["id"]: r for r in csv.DictReader(open("crm_contacts.csv", newline=""))}
rec = [json.loads(l) for l in open("enriched.jsonl")]
by_hash = collections.defaultdict(list)
for x in rec:
    if x["found"] and x["key_hash"]:
        by_hash[x["key_hash"]].append(x["id"])

DROP = {"invalid", "spam_trap", "abuse", "disposable"}
CARE = {"risky", "role", "derived"}
out = []
for x in rec:
    c = crm[x["id"]]
    if not x["found"]:
        out.append((x["id"], "unmatched", ""))
        continue
    if len(by_hash[x["key_hash"]]) > 1:
        out.append((x["id"], "duplicate", ",".join(by_hash[x["key_hash"]])))
    if x["email_status"] in DROP:
        out.append((x["id"], "email_drop", x["email_status"]))
    elif x["email_status"] in CARE:
        out.append((x["id"], "email_review", x["email_status"]))
    cd, nd = (c["domain"] or "").strip().lower(), (x["company_domain"] or "").lower()
    if nd and cd and nd != cd or x["email_type"] == "work_other":
        out.append((x["id"], "possible_job_change", f'{cd} -> {nd}'))
    if not c["title"].strip() and x["title"]:
        out.append((x["id"], "fill_title", x["title"]))

with open("review.csv", "w", newline="") as f:
    w = csv.writer(f); w.writerow(["id", "label", "detail"]); w.writerows(out)
print(collections.Counter(o[1] for o in out))

Read review.csv before anything moves. A job change is a prompt to look at the contact, not a reason to delete the old record.

Two limits shape how far you trust a label. First, email_status describes the best address we hold for the person. The script reads the entry that matches your CRM email when there is one, and falls back to the best address when there is not. Second, a verified_batch_date is the date of a verification run, not a timestamp for that one address.

Treat unmatched as unknown, not as bad. We hold 693M people, so a miss often means a thin profile, not a dead contact. Leave those rows as they are, and mark them for a manual check if the account matters.

Bounces are not refunded or credited back. Read email_status before you send, and drop the four do-not-send values from your sequences.

Step 5: Fill company fields once per domain

Company data repeats across contacts, so look each domain up once. POST /v1/companies/enrich takes a domain. A company found is one record, and a miss costs nothing. Dedupe the domains first. That is where the up to 300 company lookups come from.

bash
curl -s -X POST https://api.leadocean.io/v1/companies/enrich \
  -H "x-api-key: $LEADOCEAN_API_KEY" -H "content-type: application/json" \
  -d '{"domain": "acme.com"}' | jq '.data.company_data | {name: .company_name, employees: .company_employees.number_of_employees}'
python
import csv, json, os, time, requests

URL = "https://api.leadocean.io/v1/companies/enrich"
HEAD = {"x-api-key": os.environ["LEADOCEAN_API_KEY"]}
domains = sorted({r["domain"].strip().lower() for r in csv.DictReader(open("crm_contacts.csv")) if r["domain"].strip()})

with open("accounts.csv", "w", newline="") as f:
    w = csv.writer(f); w.writerow(["domain", "company_name", "employees"])
    for d in domains:
        res = requests.post(URL, headers=HEAD, json={"domain": d}, timeout=20)
        if res.status_code == 404:
            continue
        res.raise_for_status()
        cd = res.json()["data"].get("company_data") or {}
        w.writerow([d, cd.get("company_name"), (cd.get("company_employees") or {}).get("number_of_employees")])
        time.sleep(0.2)

Step 6: Write only the changes back

Build one import file from the reviewed labels. Match on your CRM id and touch only the fields you chose. Here it fills blank titles and stamps the email status. It leaves job-change and duplicate rows to a person.

python
import csv, json

rec = {x["id"]: x for x in map(json.loads, open("enriched.jsonl")) if x["found"]}
crm = {r["id"]: r for r in csv.DictReader(open("crm_contacts.csv", newline=""))}
with open("crm_update.csv", "w", newline="") as f:
    w = csv.writer(f); w.writerow(["id", "title", "email_status", "checked_on"])
    for i, x in rec.items():
        title = crm[i]["title"].strip() or x["title"] or ""
        w.writerow([i, title, x["email_status"] or "", "2026-10-01"])

Import crm_update.csv with your CRM's own importer, in update mode, matched on id. Merge the flagged duplicates by hand in the CRM. The dataset refreshes monthly, so schedule the run monthly, not daily.

Keep a changelog. Save review.csv and crm_update.csv with the run date, so you can say later why a title changed. On the next run, re-enrich only the contacts you touched last time and the ones added since. That keeps a monthly pass well under the first one in records. The same flow works for a table of leads you have not mailed yet: enrich, drop the bad addresses, then load.

What you get

The run leaves four files: enriched.jsonl, review.csv, accounts.csv and crm_update.csv. Here is the response shape from the API reference for one found person, with placeholder values.

json
{
  "success": true,
  "data": {
    "profile_data": { "profile_full_name": "Jane Doe" },
    "contact_data": {
      "has_email": true,
      "email_status": "verified",
      "email_type": "work",
      "contact_emails": [
        { "email": "jane.doe@acme.com", "type": "work", "status": "verified" }
      ],
      "contact_current_experiences": [
        { "job_title": "Head of Sales", "company_name": "Acme", "company_domain": "acme.com" }
      ]
    },
    "meta": { "schema": "leadocean.person.v1", "key_hash": "<hash>", "fetched_at": "<date>" }
  },
  "meta": { "credits": 1, "key_hash": "<hash>" }
}

A review.csv looks like this. These rows are placeholders to show the layout, not output from a real run.

text
id,label,detail
1001,email_drop,invalid
1002,possible_job_change,oldco.com -> acme.com
1003,duplicate,1003,1044
1050,fill_title,Head of Sales
1071,unmatched,

Two free MCP calls help you set the thresholds. On 1 October 2026 leadocean_list_enum_values returned 13 email_status values: verified, catch_all_valid, catch_all, risky, unknown, untested, invalid, role, disposable, spam_trap, abuse, derived and none. The docs mark verified and catch_all_valid as safe to send, and invalid, spam_trap, abuse and disposable as do not send.

The same day leadocean_count_leads with jobLevel: ["VP"], jobFunction: ["Sales & Business Development"], country: ["US"] and emailType: ["work"] returned total: 96532, totalIsExact: true and credits: 0. That counts people with a work email whose status is verified, catch_all_valid or catch_all. It is not everyone we hold. Use a count like this to size net-new contacts after the clean-up.

Troubleshooting

ErrorCauseFix
401Missing, wrong or revoked keyRe-copy the key from app.leadocean.io and re-export $LEADOCEAN_API_KEY.
402Free plan: the 1,000 records are spentCheck GET /v1/account, then move to Solo at $97 or Agency at $497 a month on pricing. Free records do not reset.
403The key has no enrich scopeCreate or edit a key with the scope in the app.
404We hold nothing for that emailNot a failure and not billed. Label the row unmatched and leave it alone.
429Above your plan's rate per account (5 a second on Free, 200 on Agency)Wait the Retry-After seconds. The loop above sleeps 200 ms and stays near 5 a second.
400Two identifiers in one request, or noneSend exactly one of linkedin_url, email, phone or person_id.

FAQ

How many records does a clean-up cost?

One per person or company found. A 404 is free. The 500-contact example spends up to 500 on people and up to 300 on companies, so up to 800 of the 1,000 free records. Solo at $97 and Agency at $497 a month have no per-record price, only a fair-use pace. See pricing.

Will this overwrite my CRM data?

No. The scripts only write files. Step 6 builds an update file with the fields you pick, and you import it in update mode. Keep your original export as the backup.

What if a contact has no email?

This recipe skips it. Enrich takes a linkedin_url, email, phone or person_id, so use the LinkedIn URL if the CRM holds one. See person enrichment for each input.

Can an AI agent do the review for me?

Yes, for the reading part. Give Claude Code the three files and the label rules. For a Salesforce setup, see Claude Code for Salesforce data.

Where do I read more?

For the checklist behind these labels, read CRM data hygiene. For company-level clean-up, see the parent company recipe. More guides are in the blog hub.

Clean up your CRM with your first 1,000 free records

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

Get your free API key →