TL;DR
- Enrich each stale contact with one call, then write back only what changed.
- A first batch of 200 contacts takes about 45 minutes and spends at most 200 of your 1,000 free records.
- A miss is a 404 and costs nothing. The dataset refreshes monthly, so run the job monthly, not daily.
CRM data decays because people change jobs and companies change domains. You cannot see that from inside the CRM. You see it when a sequence bounces or a rep calls the wrong person. This recipe finds those rows before the next send. It needs no CRM plugin, only a CSV and an API key.
Prerequisites
- A LeadOcean API key from app.leadocean.io. The free plan has no card and 1,000 records, one-off. The key needs the
enrichscope, which Free keys have. - The key in your shell as
$LEADOCEAN_API_KEY. curl,jqand Python 3.- A CRM list view you can export to CSV, with a LinkedIn URL or a work email on each row.
export LEADOCEAN_API_KEY="paste-your-key-here"Step 1: Export the stale slice from your CRM
Export contacts nobody has touched in 90 days or more. Start with the ones tied to open deals or live sequences, because a stale row there costs the most. Keep one identifier per row, a LinkedIn URL or a work email, plus the fields you want to check.
crm_id,linkedin_url,email,job_title,company_domain
c_001,https://www.linkedin.com/in/jane-doe,jane@acme.com,Head of Sales,acme.com
c_002,,john.roe@example.com,VP Marketing,example.comPrefer the LinkedIn URL when you have both. The docs call it the unambiguous key. An email works too, and it returns the person behind the address, which is how you catch someone who left the company.
There is no name plus domain lookup. A row with neither an email nor a URL goes to Step 5.
Step 2: Check your balance
Read the account before you spend anything. GET /v1/account is free. On Free, records.remaining is what is left of the 1,000.
curl -s https://api.leadocean.io/v1/account \
-H "x-api-key: $LEADOCEAN_API_KEY" | jq '.data.records'Each enriched person is one record. Revealing emails and phones adds nothing, so always send reveal_email: true.
A 200-contact batch is at most 200 records. On Free that is a fifth of your allowance. Solo at $97 a month and Agency at $497 a month have no per-record price. They have a fair-use pace: a very large run is slowed, never billed extra.
Step 3: Enrich the batch
This script reads crm.csv, calls POST /v1/people/enrich once per row and writes one JSON line per contact. It retries a 429 after one second and stops on a 401 or 402.
#!/usr/bin/env bash
## enrich.sh: crm.csv in, enriched.jsonl out
call() {
curl -s -o out.json -w '%{http_code}' https://api.leadocean.io/v1/people/enrich \
-H "x-api-key: $LEADOCEAN_API_KEY" -H "content-type: application/json" -d "$1"
}
: > enriched.jsonl
tail -n +2 crm.csv | while IFS=, read -r crm_id linkedin_url email _; do
if [ -n "$linkedin_url" ]; then body=$(jq -nc --arg v "$linkedin_url" '{linkedin_url:$v,reveal_email:true}')
else body=$(jq -nc --arg v "$email" '{email:$v,reveal_email:true}'); fi
code=$(call "$body")
[ "$code" = 429 ] && { sleep 1; code=$(call "$body"); }
case $code in
200) jq -c --arg id "$crm_id" '{crm_id:$id, hit:true,
title:.data.contact_data.contact_current_experiences[0].job_title,
company:.data.contact_data.contact_current_experiences[0].company_name,
domain:.data.contact_data.contact_current_experiences[0].company_domain,
email:.data.contact_data.contact_emails[0].email,
email_status:.data.contact_data.email_status,
email_type:.data.contact_data.email_type,
fetched_at:.data.meta.fetched_at}' out.json >> enriched.jsonl ;;
404) echo "{\"crm_id\":\"$crm_id\",\"hit\":false}" >> enriched.jsonl ;;
401|402) echo "stopped: HTTP $code at $crm_id" >&2; break ;;
*) echo "$crm_id HTTP $code" >> errors.log ;;
esac
doneThe script keeps seven fields. This is where each one comes from and where it usually lands in the CRM.
| Output field | Path in the response | CRM field |
|---|---|---|
title | contact_data.contact_current_experiences[0].job_title | Job title |
company, domain | the same object, company_name and company_domain | Account name, website |
email | contact_data.contact_emails[0].email | Email (best address first) |
email_status | contact_data.email_status | Email status |
email_type | contact_data.email_type | Email type |
fetched_at | meta.fetched_at inside data | Last enriched |
The retry covers one 429 and no more. The rate limit is per account and per plan: 5 requests a second on Free, 50 on Solo, 200 on Agency. A sequential curl loop rarely reaches it. If you parallelise the job, add real backoff.
Run it on 10 rows first. Open enriched.jsonl and compare two or three against LinkedIn by hand. Then run the other 190.
Step 4: Diff against the CRM and write back only changes
Enrichment is only useful if it changes a decision. Overwriting every field blindly is how a CRM gets worse. This script flags four cases and leaves everything else alone.
import csv, json
crm = {r["crm_id"]: r for r in csv.DictReader(open("crm.csv"))}
out = []
for line in open("enriched.jsonl"):
e = json.loads(line)
c = crm[e["crm_id"]]
if not e["hit"]:
continue
if e["domain"] and e["domain"] != c["company_domain"]:
out.append((c["crm_id"], "moved", e["company"], e["title"]))
elif e["title"] and e["title"] != c["job_title"]:
out.append((c["crm_id"], "new_title", e["company"], e["title"]))
if e["email_status"] in ("invalid", "spam_trap", "abuse", "disposable"):
out.append((c["crm_id"], "do_not_send", "", e["email_status"]))
elif e["email_type"] == "work_other":
out.append((c["crm_id"], "former_employer_email", "", e["email"]))
csv.writer(open("updates.csv", "w")).writerows([("crm_id", "reason", "company", "value")] + out)The cases map to CRM actions:
| Reason | What it means | CRM action |
|---|---|---|
moved | The current role is at a different domain | Create a task to re-qualify. Do not overwrite the account silently. |
new_title | Same company, new title | Update the title field. |
do_not_send | The best address is invalid, a spam trap, abusive or disposable | Mark the contact do-not-email. |
former_employer_email | The best address is work_other, a business address at a different company | Stop sending to it. Treat the contact as moved. |
Import updates.csv through your CRM's own CSV import or API. LeadOcean has no CRM connector, so this step is yours.
Read email_status before you act on email. Drop invalid, spam_trap, abuse and disposable. Treat risky, role and derived as your call. untested means nobody has checked the address yet, which is not a negative signal. LeadOcean gives no refund or credit for bounced emails, so the status is your only guard.
Review moved rows by hand. A person who left is a lead at a new account and a closed contact at the old one. Your CRM should record both.
Step 5: Fill the gaps from the account side
Some contacts have no email and no LinkedIn URL. Find the person by account and title, then enrich by person_id. Size the search first. count=true is a query parameter and is free with limit set to 1.
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", "C-Team"], "limit": 1}' | jq '.meta'Then pull a page and enrich the one you want. Search returns email flags, not addresses, and each person returned is one record. The person_id sits at meta.source_ids.person_id.
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", "C-Team"], "limit": 5}' \
| jq '.data[] | {name: .profile_data.profile_full_name, id: .meta.source_ids.person_id}'
curl -s https://api.leadocean.io/v1/people/enrich \
-H "x-api-key: $LEADOCEAN_API_KEY" -H "content-type: application/json" \
-d '{"person_id": "<id from the search row>", "reveal_email": true}'For hundreds of accounts, use POST /v1/exports instead of a paging loop. Bulk CSV export is included on Solo and above, not on Free. One export takes up to 50,000 rows a request and 500,000 a day. The LeadOcean app has the same export on its Exports page, with a filter builder and the record price shown before you start.
Step 6: Run it monthly
LeadOcean refreshes the dataset monthly, and each record carries its own fetched_at date. Enriching the same contact weekly buys you nothing. Schedule the job for the second of the month.
## crontab -e
0 6 2 * * cd /srv/crm-refresh && ./export_stale.sh && ./enrich.sh && python3 diff.pyRe-export only contacts not enriched in the last 30 days. Skip rows that were a 404 last month for one more cycle. A 404 is free, but the answer rarely changes in a month.
Write fetched_at to a CRM field on every hit. Next month it tells you which rows are already current. It also gives a rep a date to trust or doubt.
What you get
Step 3 writes one JSON line per contact. Here is one line, with placeholder values, in the shape the script produces.
{"crm_id":"c_001","hit":true,"title":"VP Sales","company":"Acme","domain":"acme.com",
"email":"jane@acme.com","email_status":"verified","email_type":"work","fetched_at":"<date>"}This is the record behind it. It is the response shape from the API reference, trimmed to the fields the script reads.
{
"success": true,
"data": {
"contact_data": {
"has_email": true,
"email_status": "verified",
"email_type": "work",
"contact_emails": [{ "email": "jane@acme.com", "type": "work", "status": "verified" }],
"contact_current_experiences": [{ "company_name": "Acme", "company_domain": "acme.com", "job_title": "VP Sales" }]
},
"meta": { "fetched_at": "<date>", "schema": "leadocean.person.v1" }
},
"meta": { "credits": 1 }
}Two free MCP calls on 1 October 2026 show what Step 5 can fill. leadocean_count_leads with jobLevel: ["VP"], jobFunction: ["Sales & Business Development"], country: ["US"] and employeeRange: ["51-200"] returned:
{"total":17133,"totalIsExact":true,"credits":0,"source":"leadocean",
"defaults":"Counted only addresses worth sending to (emailStatus: verified or catch_all_valid or catch_all)"}That is 17,133 mailable people, and the count spent no records. Your own accounts will give a different number. leadocean_list_enum_values for email_type returned work, work_other, work_any, personal, other, unknown and none.
Read email_status on the enriched answer, not on a search row. The docs say a person can read verified in search and untested on enrichment, and the enriched answer is the correct one.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing, wrong or revoked key | Re-copy the key from app.leadocean.io and re-export $LEADOCEAN_API_KEY. |
| 400 naming four fields | You sent two identifiers, such as an email and a LinkedIn URL | Send exactly one of linkedin_url, email, phone or person_id. |
| 402 | Free plan: the 1,000 records are spent | Free records do not reset. Move to Solo ($97) or Agency ($497) on pricing. |
| 404 | We hold nothing for that person | Not an error to fix. It costs nothing. Skip the row. |
| 429 | Above your plan's rate (5 a second on Free, 200 on Agency), or a paid plan's fair-use window is reached | Wait the Retry-After seconds (1 for the rate, 60 while paced). Do not loop into a pace. |
| Count of 0, or filter errors | An enum value that does not exist, such as jobLevel: "Vice President" | Resolve it with leadocean_list_enum_values or GET /v1/enums/<name> and pass the exact string. |
Search stops with no nextCursor | One search pages through at most 10,000 rows | Narrow the filters and run several smaller searches. |
FAQ
How often should I re-enrich CRM contacts?
Monthly. The dataset refreshes monthly, and fetched_at on each record tells you how old the answer is. Weekly runs return the same record and spend the same record.
Do I need a LinkedIn URL for every contact?
No. A work email also works. About one person in five has no LinkedIn URL, so those people are reachable only through person_id from a search row.
What does a failed lookup cost?
Nothing. A 404, a 429, a 402 and a 503 are never metered. Only a person you receive counts one record.
Can I do this from Claude instead of a script?
For a handful of contacts, yes. The hosted MCP server at https://api.leadocean.io/mcp has 13 tools, and get_lead costs one record. A get_lead that finds nobody costs nothing. For hundreds of rows, the script is faster and easier to audit.
Will this write to my CRM for me?
No. LeadOcean returns data. You push changes through your CRM's CSV import or API, or through a workflow tool covered on the integrations page.
Does it matter that Free is one-off?
Yes. The 1,000 free records never reset, so spend them on a 200-contact test and a few gap-fill lookups. Plans are on pricing.
Related: how to keep CRM data fresh automatically, best CRM data enrichment tools and what CRM enrichment adds to your sales data. More recipes live in the blog hub.
Refresh your stale CRM contacts with your first 1,000 records
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →