TL;DR
- Keep a ledger of when each contact was last checked. Re-enrich only the contacts whose tier says they are due, then write back only the fields that changed.
- Setup takes about 90 minutes. A first run over 200 contacts spends at most 200 of your 1,000 free records.
- The dataset refreshes monthly, so no tier should run faster than every 30 days. A miss is a 404 and costs nothing.
A one-off enrichment pass goes stale within a quarter. People change jobs and addresses start to bounce. Crustdata's guide fixes this with watchers that push a webhook when a record changes (checked October 2026). LeadOcean has no change webhooks. So this recipe uses a scheduled loop that spends records only where the age of the data justifies it.
Prerequisites
- A LeadOcean API key from app.leadocean.io. The free plan has no card and 1,000 records, one-off. Free keys get the enrich scope.
- The key in your shell as
$LEADOCEAN_API_KEY. - Python 3 with
requests, and the SQLite that ships with Python. - A CSV export of your CRM contacts with a LinkedIn URL or a work email on each row, plus a tier you assign.
export LEADOCEAN_API_KEY="paste-your-key-here"
pip install requestsStep 1: Tier your contacts and set a cadence
Tier by what a stale row costs you. Contacts on open deals go in tier A. Active leads go in tier B. Everything else goes in tier C.
Match the cadence to how the data moves. The dataset refreshes monthly, and an enrich call returns the record as it stands. Calling the same contact weekly mostly returns the same record and spends another record each time.
A: 30 # open deals, live sequences
B: 90 # active leads
C: 180 # everything elseSave that as cadence.yaml, with days between checks. Put the tier in your CSV as a column.
crm_id,tier,linkedin_url,email,title,company_domain
c_001,A,https://www.linkedin.com/in/jane-doe,jane@acme.com,Head of Sales,acme.com
c_002,B,,john.roe@example.com,VP Marketing,example.comStep 2: Build the freshness ledger
One SQLite table is the whole state. You do not need a queue, a worker or a webhook endpoint to run it. It holds the identifiers, the last values you saw, and when you last checked.
CREATE TABLE contacts (
crm_id TEXT PRIMARY KEY,
tier TEXT NOT NULL,
linkedin_url TEXT, email TEXT, person_id TEXT,
title TEXT, company_domain TEXT, email_status TEXT,
last_checked TEXT, -- ISO date, NULL = never
state TEXT DEFAULT 'ok' -- ok | miss | moved | bad_email
);Load the CSV with sqlite3 ledger.db ".mode csv" ".import --skip 1 crm.csv contacts". Add the missing columns as nulls, or load through a short Python loop.
Step 3: Select what is due, inside a budget
The query returns contacts past their tier's age, oldest first. The LIMIT is your monthly cap, so a bad cadence cannot drain the account.
SELECT crm_id FROM contacts
WHERE last_checked IS NULL
OR julianday('now') - julianday(last_checked) >=
CASE tier WHEN 'A' THEN 30 WHEN 'B' THEN 90 ELSE 180 END
ORDER BY tier, last_checked
LIMIT 500;Check the balance first. GET /v1/account is free.
curl -s https://api.leadocean.io/v1/account \
-H "x-api-key: $LEADOCEAN_API_KEY" | jq '.data.records'Each contact we hold is one record. Reveal adds nothing. On Free the cap is the 1,000 you hold. On the paid plans (Solo at $97 a month, Agency at $497 a month) there is no per-record price. A very large month is paced down by fair use, not billed extra.
Size the monthly spend before you pick a cadence. Take 100 tier A contacts, 600 tier B and 3,000 tier C. Per month that is 100 for A, about 200 for B (600 over three months) and about 500 for C (3,000 over six months). The ceiling is roughly 800 records a month, and misses cost nothing, so the real figure is lower. Swap in your own counts.
Step 4: Enrich the due contacts
This loop calls POST /v1/people/enrich once per due row. It prefers the LinkedIn URL, then the email, then a stored person_id. It sends exactly one identifier, because two is a 400.
import os, sqlite3, time, requests
API = "https://api.leadocean.io/v1/people/enrich"
HDR = {"x-api-key": os.environ["LEADOCEAN_API_KEY"], "content-type": "application/json"}
def enrich(row):
for field in ("linkedin_url", "email", "person_id"):
if row[field]:
body = {field: row[field], "reveal_email": True}
break
else:
return None, 0
for _ in range(2):
r = requests.post(API, headers=HDR, json=body, timeout=30)
if r.status_code == 429:
time.sleep(1); continue
if r.status_code in (401, 402, 403):
raise SystemExit(f"stop: {r.status_code} {r.text[:120]}")
return (r.json()["data"] if r.status_code == 200 else None), r.status_code
return None, 429A 404 means we hold nothing for that person. It costs nothing, so mark the row miss and stamp last_checked so it waits out its tier before the next try.
Step 5: Diff and flag what changed
Compare the new values to the ledger. Write back only the differences. The two signals worth a human look are a changed company domain and a bad email status.
BAD = {"invalid", "spam_trap", "abuse", "disposable"}
def diff(old, data):
cd = data["contact_data"]
exp = (cd.get("contact_current_experiences") or [{}])[0]
new = {
"title": exp.get("job_title"),
"company_domain": exp.get("company_domain"),
"email_status": cd.get("email_status"),
}
changes = {k: v for k, v in new.items() if v and v != old[k]}
moved = "company_domain" in changes and bool(old["company_domain"])
bad = new["email_status"] in BAD or cd.get("email_type") == "work_other"
state = "moved" if moved else "bad_email" if bad else "ok"
return changes, stateRoute each state to an action:
| State | What it means | Action |
|---|---|---|
ok | Nothing changed, or only a title moved. | Update the title in the CRM. No alert. |
moved | The company domain differs from your ledger. | Alert the owner. Pause the sequence. Treat as a new lead at the new company. |
bad_email | Status is invalid, spam_trap, abuse or disposable, or the address is work_other. | Stop sending to that address. Look for a current one in the same response. |
miss | We hold nothing for this person. | Leave the CRM row alone and retry at the tier age. |
Since 17 October 2026 the same response carries career history in resume_data.experiences: past roles with title, company name and dates. On a moved row, the old employer from your ledger should now appear there. That is a cheap check that the move is real and not a data error. Past employers carry no company_id yet, so match on company name.
work_other means the best address belongs to a former employer, so it is the likeliest to bounce. There is no refund for a bounce, so read email_status before every send. Apply changes to the ledger, set last_checked to today, and write the same fields to your CRM through its own import or API.
Step 6: Schedule it monthly
Run the loop once a month, at 06:00 on the 2nd in the line below. Daily runs are safe because Step 3 selects nothing that is not due, but the data moves monthly.
0 6 2 * * cd /srv/crm-refresh && python3 refresh.py >> refresh.log 2>&1Have refresh.py print a summary at the end. That summary is your audit trail.
Do a dry run first. Run Steps 3 to 5 on 20 contacts, print the diff and do not write to the CRM. Check that the moved rows are real moves. Then switch writes on.
Tune it after two runs
Read the summary after the second run. If tier B shows few moved rows, stretch it to 120 days. If tier A shows many, the cadence is right and the cost is justified. Move a contact up a tier when it enters an open deal, and down when the deal closes. Change the tier column in the ledger, and the next run picks it up.
Keep the ledger across runs. It is also your history. Stamp each change with a date and you can answer when a contact moved, not only that they did.
What you get
Two things come out of a run. The first is the record returned by each enrich call. 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_emails": [{ "email": "jane@acme.com", "type": "work", "status": "verified" }],
"contact_current_experiences": [
{ "company_name": "Acme", "company_domain": "acme.com", "job_title": "Head of Sales" }
]
},
"resume_data": {
"experiences": [{ "company_name": "Example Corp", "job_title": "Sales Director", "company_id": null }]
}
},
"meta": { "schema": "leadocean.person.v1", "fetched_at": "<date>", "key_hash": "..." }
}Store meta.fetched_at in the ledger next to last_checked. It is the date LeadOcean last fetched that record, so it tells you how old the data is, not only when you asked.
The second is your own run summary. This is the format refresh.py should print, with placeholder numbers.
due: 200 enriched: 168 miss: 32 records spent: 168
changed: 41 (title 29, company_domain 9, email_status 11)
moved: 9 bad_email: 6Freshness also means people you do not hold yet. The free count tool sized one segment on 2026-10-21: VP, Sales & Business Development, country US. It returned 129,494 people. That count covers only addresses we mark verified, catch_all_valid or catch_all, which is the tool's default. The same filters with no email condition match 350,517.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing or wrong x-api-key. | Re-export $LEADOCEAN_API_KEY from app.leadocean.io and rerun. |
| 403 | The key lacks the enrich scope. | Create a key with the enrich scope. Free keys have it. |
| 402 | Out of records. On Free this is a refusal after your 1,000. | Lower the LIMIT in Step 3, or move to a paid plan. Read records from GET /v1/account. |
| 429 | Above the plan's rate limit: 5 requests a second on Free, 50 on Solo, 200 on Agency. | Back off one second, as Step 4 does. On Free, add a short sleep between calls. |
| 400 | Two identifiers sent in one enrich call. | Send exactly one of linkedin_url, email, phone or person_id. |
| 404 | We hold nothing for that person. | Not a failure and not billed. Mark the row miss and retry after its tier age. |
FAQ
How often should I re-enrich a CRM?
Every 30 days for contacts on open deals, and no faster. The dataset refreshes monthly, so a faster loop returns the same record. Use 90 days for active leads and 180 for the rest.
Can LeadOcean tell me when a contact changes jobs?
No. LeadOcean has no change webhooks or watchers. You find the change by re-enriching and comparing, as in Step 5. Tier A at 30 days keeps the lag to about a month.
Should I use exports for the backfill?
Only for building new lists, and only on a paid plan: bulk CSV export is not on Free. POST /v1/exports takes up to 50,000 rows a request and 500,000 a day, but it builds a list from filters, not from your CRM ids. To refresh rows you already hold, enrich them by identifier.
What does this cost?
One record per contact we hold, per run. Free gives you 1,000 records, one-off. Solo is $97 a month and Agency is $497 a month, both with a fair-use pace instead of credits. See pricing.
What if a contact has no LinkedIn URL or email?
There is no name plus domain lookup. Search by domain, then enrich by person_id. The CRM refresh how-to walks through that case. For tool options, see best CRM enrichment tools and the CRM enrichment use case.
Keep your CRM fresh without paying per refresh
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →