You refresh a candidate database by re-enriching each stored profile and comparing the result to what you hold. LeadOcean's enrich endpoint takes a LinkedIn URL, an email, a phone or a person_id. It returns the current role and the best email with its status. A short script turns that into a list of candidates who moved and addresses that went stale.
This guide is for a technical buyer with an existing table of candidates. It uses the REST API, not a UI, so you can run it from cron.
TL;DR
- Re-enrich each candidate by LinkedIn URL (or email), then diff current employer, title and email status against your stored row.
- Time: about 30 minutes to set up. 1,000 candidates take about 200 seconds on Free (5 requests a second) and about 20 seconds on Solo (50 a second).
- Records: 1 per candidate returned. A candidate we hold nothing for is a 404 and costs nothing. Sizing a top-up pool is free.
Prerequisites
- A LeadOcean API key in
$LEADOCEAN_API_KEY. Free gives 1,000 records for life, no card, with both the search and enrich scopes (sign up). - A
candidates.csvwith the columnsid,linkedin_url,email,company_domain,job_title. Examples use placeholders such as Jane Doe and acme.com. - Python 3 with
requests. - Know your pace. Solo is paced at 10k enrichments every 5 hours and 50k a week. Agency is paced at 1M enrichments a day (pricing, October 2026).
Step 1: Size the refresh and the top-up pool
Count first. Counts are free and never spend a record (limits, October 2026). Your stored rows are the refresh job. The count tells you how many new candidates match the same profile.
curl -s "https://api.leadocean.io/v1/people/search?count=true&limit=1" \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "content-type: application/json" \
-X POST \
-d '{"jobFunction":["Engineering"],"jobLevel":["Staff"],"country":["GB"]}' \
| jq '.meta | {total, totalIsExact, distinctCompanies, countOnly}'count=true goes in the query string, not the body. The body rejects unknown fields. Enum values are exact strings: Staff, not staff, and GB, not United Kingdom. Check them with GET /v1/enums/job_level.
Step 2: Enrich every stored candidate
Send exactly one identifier per call. Prefer linkedin_url: it is unambiguous. Fall back to email. Set reveal_email to get the address list with each status.
import csv, os, sys, time, requests
URL = "https://api.leadocean.io/v1/people/enrich"
HEAD = {"x-api-key": os.environ["LEADOCEAN_API_KEY"], "content-type": "application/json"}
PAUSE = 0.2 # 5 requests a second on Free. Use 0.02 on Solo.
def enrich(row):
body = {"reveal_email": True}
if row["linkedin_url"]:
body["linkedin_url"] = row["linkedin_url"]
elif row["email"]:
body["email"] = row["email"]
else:
return "skip", None
r = requests.post(URL, headers=HEAD, json=body, timeout=30)
if r.status_code == 404:
return "miss", None # costs nothing
if r.status_code == 402:
sys.exit("Free records spent. Upgrade to continue.")
if r.status_code == 429:
if r.headers.get("X-Quota-State") == "paced":
sys.exit("Fair-use pace reached. Resets " + r.headers.get("X-Quota-Reset", "later"))
time.sleep(int(r.headers.get("Retry-After", "1")))
return enrich(row)
r.raise_for_status()
return "ok", r.json()["data"]A 404 means we hold nothing for that person. It is not your mistake and costs nothing. Do not retry it.
Step 3: Diff against what you hold
The current role is in contact_data.contact_current_experiences[]. Compare its company_domain and job_title to your row. Read the best email from contact_data.contact_emails[], which comes best first.
def diff(row, p):
exp = (p["contact_data"].get("contact_current_experiences") or [{}])[0]
mails = p["contact_data"].get("contact_emails") or []
best = mails[0] if mails else {}
flags = []
if exp.get("company_domain") and exp["company_domain"] != row["company_domain"]:
flags.append("moved")
elif exp.get("job_title") and exp["job_title"] != row["job_title"]:
flags.append("retitled")
if best.get("type") == "work_other":
flags.append("email_at_former_employer")
if best.get("status") in ("invalid", "spam_trap", "abuse", "disposable"):
flags.append("drop_email")
return {
"id": row["id"], "flags": "|".join(flags),
"new_company": exp.get("company_domain", ""),
"new_title": exp.get("job_title", ""),
"best_email": best.get("email", ""),
"email_status": best.get("status", ""),
}A work_other address is a business address at a different company, usually one the candidate has left. It is the most likely to bounce (enrich docs, October 2026). Treat it as a stale-email signal even when the status still reads verified.
Step 4: Run it and write the changes
out = csv.DictWriter(open("refresh.csv", "w", newline=""),
fieldnames=["id","flags","new_company","new_title","best_email","email_status"])
out.writeheader()
spent = misses = 0
for row in csv.DictReader(open("candidates.csv")):
state, person = enrich(row)
if state == "miss":
misses += 1
elif state == "ok":
spent += 1
out.writerow(diff(row, person))
time.sleep(PAUSE)
print(f"records spent: {spent}, misses (free): {misses}")Write the changed fields back to your database, not the whole record. Keep your own refreshed_at. The dataset is refreshed monthly and each record carries its own fetched_at date in meta, so a weekly run mostly re-buys unchanged data.
The script is idempotent: running it twice gives the same flags. It never deletes a candidate on a miss, because a 404 only says we hold nothing, not that the person is gone.
Keep four fields per candidate besides the identifier: company_domain, job_title, refreshed_at and the last email_status. Do not overwrite the old employer. Append it to a history table with the date you saw the move.
Step 5: Act on each flag
A flag is only useful if it maps to an action. Keep the mapping in code, not in a recruiter's head.
| Flag | What it means | Action |
|---|---|---|
moved | Current employer domain differs from your row | Update employer and title, re-score the candidate, notify the owning recruiter. |
retitled | Same employer, new title | Update the title. Consider a promotion outreach angle. |
email_at_former_employer | Best address is type work_other | Stop sending to it. Look for a work address in the same contact_emails[] list. |
drop_email | Status is invalid, spam_trap, abuse or disposable | Remove the address. Do not retry it. |
| blank | Nothing changed | Write refreshed_at and move on. |
Statuses are verification results, not guesses. verified and catch_all_valid are the two to send to. untested is not a negative signal: nobody has checked that address yet (enrich docs, October 2026).
No refund or credit-back applies to bounces, so read email_status before a send, not after.
Step 6: Schedule it and top up
Run the script monthly from cron. For a large table, split candidates.csv by recruiter or segment and run the segments on different days so you stay inside your plan's pace.
Cron line, first of the month at 06:00:
0 6 1 * * cd /srv/refresh && python3 refresh.py >> refresh.log 2>&1To replace candidates who moved out of scope, export a fresh pool with the same filters you sized in Step 1. Bulk CSV export is not on Free. It is included on Solo, up to 50,000 rows a request and 500,000 a day.
curl -s -X POST https://api.leadocean.io/v1/exports \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "content-type: application/json" \
-d '{"name":"gb-staff-eng-topup","filters":{"jobFunction":["Engineering"],"jobLevel":["Staff"],"country":["GB"]},"limit":5000,"preset":"search"}'Poll GET /v1/exports/{id}, then download from /v1/exports/{id}/download. Every row costs one record.
Step 7: Check the cost before a big run
Cost scales with rows, not with calls. Read GET /v1/account first. It is free and shows your pace windows with what each has used (limits, October 2026).
curl -s https://api.leadocean.io/v1/account -H "x-api-key: $LEADOCEAN_API_KEY" | jq '.data'- 1,000 candidates: at most 1,000 records. That is the whole Free allowance, which never resets.
- 10,000 candidates: at most 10,000 records. One window of the Solo pace, which is 10k enrichments every 5 hours.
- 100,000 candidates: at most 100,000 records. Split it over days on Solo (50k enrichments a week), or run it on Agency (1M enrichments a day).
Those are upper bounds: every 404 in the batch drops out of the bill.
What you get
The Step 1 count, run with the free leadocean_count_leads MCP tool on 2026-10-21:
{
"total": 248440,
"totalIsExact": true,
"totalAll": 985902,
"distinctCompanies": 37381,
"credits": 0
}total counts addresses worth sending to (verified, catch_all_valid or catch_all), the tool's default. totalAll is the same filters with no email filter. Credits spent: 0.
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",
"contact_emails": [
{ "email": "jane@acme.com", "type": "work", "status": "verified" }
],
"contact_current_experiences": [
{ "company_domain": "acme.com", "job_title": "Staff Engineer" }
]
},
"resume_data": { "experiences": [] }
},
"meta": { "credits": 1 }
}An illustrative refresh.csv row, also placeholder.
id,flags,new_company,new_title,best_email,email_status
c-0042,moved|email_at_former_employer,acme.com,Staff Engineer,jane@oldco.example,verifiedNote that since October 2026 resume_data also holds past roles, education and certifications, so you can store the move history. Past employers carry no company_id yet.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing, wrong or revoked key | Check x-api-key and that $LEADOCEAN_API_KEY is exported in the cron shell. |
| 400 | Two identifiers sent, or none | Send exactly one of linkedin_url, email, phone or person_id. |
| 404 | Nobody matched | Free. Keep the old row and mark it unrefreshed. |
| 429 | Above the plan's rate (5 a second on Free, 200 on Agency) | Honour Retry-After. If X-Quota-State is paced, stop and wait for X-Quota-Reset. |
| 402 | Free plan's 1,000 records spent | They do not reset. Upgrade to continue (limits, October 2026). |
| Export refused | Over 50,000 rows a request or 500,000 a day | Split the filters and run the pieces on separate days. |
FAQ
How often should I refresh a candidate database?
Monthly. The dataset is refreshed monthly, so a run every week mostly spends records on unchanged data. Refresh sooner only for candidates you are about to contact.
How many records does a refresh cost?
One per candidate that comes back, with or without reveal_email. A 404 and a count cost nothing. A table of 10,000 candidates costs at most 10,000 records, and fewer once the misses drop out. That is above the 1,000 on Free and inside the Solo pace of 10k enrichments every 5 hours.
Can I refresh by email instead of LinkedIn URL?
Yes. Send email in place of linkedin_url. For people with no LinkedIn URL, search returns a person_id you can send instead. Roughly one person in five has no LinkedIn URL on file.
Where can I read more?
For a database built from scratch, see build an auto-updating candidate database. To compare sources, see best B2B databases and best B2B data providers in the UK. More guides are in the blog. Plans are on pricing.
Refresh your candidate database from one script
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →