CRM hygiene is a four-part job: remove duplicates, drop dead addresses, flag people who changed jobs, and fill the gaps. This recipe does all four from a CSV export and a shell. No CRM plugin is involved.
You enrich each contact by email. That one call returns the email status, the email type and the current employer. A contact we hold nothing for is a 404 and costs nothing.
TL;DR
- Export contacts to CSV, dedupe locally, enrich each unique email once, then sort every contact into keep, review, moved on or drop.
- It takes about 45 minutes for 500 contacts. It spends up to 500 records, plus about 10 for the spot-check and the backfill.
- Misses are free, so the real cost is the number of contacts we hold. The free plan (1,000 records, one-off) covers the example.
Prerequisites
- A CRM that exports contacts to CSV, with the email in the first column.
- A LeadOcean API key from app.leadocean.io. Free keys get both the search and enrich scopes.
- The key in your shell as
$LEADOCEAN_API_KEY. You also needcurlandjq.
export LEADOCEAN_API_KEY="paste-your-key-here"Step 1: Dedupe the export (free, local)
Duplicates cost you records if you enrich them twice, so remove them first. Lowercase every address, trim spaces, drop anything that is not shaped like an email, and keep one of each.
tail -n +2 crm_contacts.csv \
| cut -d, -f1 | tr -d '"' | tr 'A-Z' 'a-z' | sed 's/^ *//;s/ *$//' \
| grep -E '^[^@ ]+@[^@ ]+\.[^@ ]+$' | sort -u > emails.txt
wc -l < emails.txtCompare that count with the row count of the export. The gap is your duplicate and junk-address rate. Fix that in the CRM with a merge, not here.
Step 2: Check your balance
GET /v1/account is free. It shows your plan and how many records are left, so you know whether the run fits before you start.
curl -s https://api.leadocean.io/v1/account \
-H "x-api-key: $LEADOCEAN_API_KEY" | jq '.data.records | {used, remaining}'If remaining is below the line count of emails.txt, run the first part of the file now and the rest later. A free account does not refill, so the 402 in the troubleshooting table is final.
Step 3: Enrich one contact and read the answer
Enrich by email with POST /v1/people/enrich. Send exactly one identifier. Send reveal_email: false: you want the verdict and the role, not new addresses. Left out, it defaults to true on an email lookup. The cost is one record either way.
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":false}' \
| jq '.data.contact_data | {email_status, email_type,
role: .contact_current_experiences[0] | {job_title, company_name, company_domain}}'Three fields drive the whole recipe:
email_statussays whether the address is deliverable.email_typeofwork_othermeans the address belongs to a former employer. That is the likeliest sign the person moved on.contact_current_experiences[0]is the role they hold now.
The same record also carries resume_data.experiences[], the person's past roles with company names and dates. Use it to confirm a move: the CRM's old employer should appear there with an end date. Past employers carry no company_id yet, so match them on name.
Step 4: Enrich the whole list
Loop over emails.txt and write one JSON line per contact. Branch on the HTTP code. 200 is a hit and costs one record. 404 means we hold nothing and costs nothing. 402 or 429 stops the run so you can read the error.
: > enriched.jsonl
while read -r email; do
resp=$(curl -s -w '\n%{http_code}' -X POST https://api.leadocean.io/v1/people/enrich \
-H "x-api-key: $LEADOCEAN_API_KEY" -H "content-type: application/json" \
-d "$(jq -n --arg e "$email" '{email:$e, reveal_email:false}')")
code=${resp##*$'\n'}; body=${resp%$'\n'*}
case $code in
200) echo "$body" | jq -c --arg e "$email" '
((.data.contact_data.contact_current_experiences // [])[0] // {}) as $r
| {crm_email:$e, found:true,
status:.data.contact_data.email_status,
type:.data.contact_data.email_type,
title:$r.job_title, company:$r.company_name, domain:$r.company_domain}' >> enriched.jsonl ;;
404) jq -nc --arg e "$email" '{crm_email:$e, found:false}' >> enriched.jsonl ;;
*) echo "stopped at $email with HTTP $code" >&2; break ;;
esac
done < emails.txt
wc -l enriched.jsonlThis runs one request at a time, under the per-account rate of every plan (5 a second on Free, 50 on Solo, 200 on Agency). For 500 contacts expect a few minutes, depending on your connection.
Step 5: Bucket every contact
Turn the status and type into one action per contact. The order matters: a dead address is a drop even when the person has moved.
jq -s 'map(. + {action:
(if (.found|not) then "no_match"
elif (.status|IN("invalid","spam_trap","abuse","disposable")) then "drop"
elif .type=="work_other" then "moved_on"
elif (.status|IN("risky","role","derived","unknown","none")) then "review"
elif .status=="untested" then "untested"
else "keep" end)})' enriched.jsonl > classified.json
jq -r 'group_by(.action)[] | "\(.[0].action)\t\(length)"' classified.jsonWhat each bucket means in practice:
- keep:
verified,catch_all_validorcatch_all. Safe to send to. - drop:
invalid,spam_trap,abuse,disposable. Suppress them. - moved_on: the address is a former employer's. Find the replacement in Step 6.
- review:
risky,role(a shared mailbox such as info@),derived,unknownor no email. Your call. - untested: nobody has checked it yet. That is not a negative signal, but verify before a big send.
- no_match: we hold nothing. Check these by hand or leave them.
Load classified.json back into your CRM with its own import-and-update-by-email feature. Write the action into a custom field so you can filter on it.
Step 6: Backfill accounts where the contact left
A moved_on contact leaves a gap at the account. List those domains, size the replacements for free, then pull a handful.
jq -r '.[] | select(.action=="moved_on") | .crm_email | split("@")[1]' classified.json | sort -uSize one account first. A search with count=true as a query parameter and limit 1 spends no records. It returns meta.total and no rows.
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":["Sales & Business Development"],"limit":1}' | jq '.meta'Then pull the real rows. Each person returned is one record. Search rows carry meta.source_ids.person_id and email flags, not addresses. Enrich by person_id when you want the address.
curl -s https://api.leadocean.io/v1/people/search \
-H "x-api-key: $LEADOCEAN_API_KEY" -H "content-type: application/json" \
-d '{"domain":["acme.com"],"jobLevel":["VP","Director"],
"jobFunction":["Sales & Business Development"],
"emailStatus":["verified","catch_all_valid","catch_all"],"limit":5}' \
| jq '.meta | {count, credits, total, nextCursor}'You can run the sizing step from an AI client too. The LeadOcean MCP server at https://api.leadocean.io/mcp has leadocean_count_leads, which is free.
Use leadocean_count_leads with title ["Head of Sales"], country ["US"], facets false.
Then repeat with emailStatus ["verified"]. Report both totals and say what each counted.What you get
Step 3 returns the person record in the leadocean.person.v1 schema. This is the response shape from the API reference, 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_current_experiences": [
{ "job_title": "Head of Sales", "company_name": "Acme", "company_domain": "acme.com" }
]
},
"meta": { "schema": "leadocean.person.v1", "fetched_at": "<date>" }
},
"meta": { "credits": 1 }
}Step 4 writes one line per contact. This is the shape of those lines, with placeholder values.
{"crm_email":"jane.doe@acme.com","found":true,"status":"verified","type":"work","title":"Head of Sales","company":"Acme","domain":"acme.com"}
{"crm_email":"john.roe@example.com","found":false}The free MCP count from the last prompt, run on 1 October 2026, for "Head of Sales" in the US:
leadocean_count_leads title ["Head of Sales"], country ["US"]
default mailable (verified, catch_all_valid, catch_all): 5,480
leadocean_count_leads title ["Head of Sales"], country ["US"], emailStatus ["verified"]
verified only: 1,555
credits spent: 0Read the two numbers as a range. People search cannot see per-address verification the way enrichment can. A verified count from search is an upper bound, and the enriched answer is the correct one. That is why Step 4 re-checks each contact.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing, wrong or revoked key | Re-copy the key from app.leadocean.io and export $LEADOCEAN_API_KEY again. |
| 402 | Free plan: the 1,000 one-off records are spent | Check GET /v1/account, run a shorter list, or move to Solo or Agency on pricing. Free records do not reset. |
| 403 | The key lacks the enrich scope | Use a key with the scope. Free keys get both search and enrich. |
| 429 | Above your plan's rate on one key (5 a second on Free, 200 on Agency) | Slow the loop and wait the Retry-After seconds. Step 4 sends one request at a time. |
| 400 or an empty search | A filter value outside the catalogue, or two identifiers on an enrich call | Send one identifier. Resolve enum values with GET /v1/enums/<name>, for example Sales & Business Development. |
| 404 on enrich | We hold nothing for that person | Not an error. It costs no record. The script writes found:false. |
FAQ
How many records does a CRM clean cost?
One per contact we hold, because each hit is one record. A miss is free. A 500-contact export spends at most 500, and Free has 1,000. Solo at $97 a month and Agency at $497 a month have no per-record price, only a fair-use pace. See pricing.
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 6 shows the search half.
Does a verified status guarantee no bounces?
No. LeadOcean gives no refund or credit for bounced emails, so read email_status before you send. catch_all means the domain accepts everything, so it cannot be confirmed either way.
How often should I run this?
Monthly is the shortest useful gap. The dataset is refreshed monthly, and each record carries its own fetched_at date. Run it before any large send and after a big import.
Where do I learn the basics first?
Start with what CRM hygiene is and how to enrich and clean CRM records. For account-level fields, see the parent company clean-up recipe. More recipes are in the blog hub.
Clean your CRM with 1,000 free records
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →