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
enrichscope. - 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
requestsinstalled, andcurlfor the checks.
export LEADOCEAN_API_KEY="paste-your-key-here"
pip install requestsStep 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.
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.
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.
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.
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_hashare one person. The docs say the same person reached by any door carries the same value. - Bad address: read
email_status.invalid,spam_trap,abuseanddisposableare drops.risky,roleandderivedare your call. - Possible job change: the current employer's domain differs from the CRM
domain. Anemail_typeofwork_otherpoints the same way, because it means a former employer. - Fill: the CRM title is blank and we hold one.
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.
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}'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.
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.
{
"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.
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
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing, wrong or revoked key | Re-copy the key from app.leadocean.io and re-export $LEADOCEAN_API_KEY. |
| 402 | Free plan: the 1,000 records are spent | Check GET /v1/account, then move to Solo at $97 or Agency at $497 a month on pricing. Free records do not reset. |
| 403 | The key has no enrich scope | Create or edit a key with the scope in the app. |
| 404 | We hold nothing for that email | Not a failure and not billed. Label the row unmatched and leave it alone. |
| 429 | Above 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. |
| 400 | Two identifiers in one request, or none | Send 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 →