Pre-Black Friday: 20% off, for as long as you stayEARLYBFon Solo and AgencyEnds Nov 28Start free

How-to guide

How to Refresh a Candidate Database (With a People API)

One enrich call per stored candidate, one diff, one monthly cron. 1 record per candidate that comes back, nothing for a miss.

Get your free API key →Free to start. No credit card. 1,000 records to spend whenever you like.

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.csv with the columns id, 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.

bash
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.

python
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.

python
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

python
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.

FlagWhat it meansAction
movedCurrent employer domain differs from your rowUpdate employer and title, re-score the candidate, notify the owning recruiter.
retitledSame employer, new titleUpdate the title. Consider a promotion outreach angle.
email_at_former_employerBest address is type work_otherStop sending to it. Look for a work address in the same contact_emails[] list.
drop_emailStatus is invalid, spam_trap, abuse or disposableRemove the address. Do not retry it.
blankNothing changedWrite 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:

bash
0 6 1 * * cd /srv/refresh && python3 refresh.py >> refresh.log 2>&1

To 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.

bash
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).

bash
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:

json
{
  "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.

json
{
  "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.

csv
id,flags,new_company,new_title,best_email,email_status
c-0042,moved|email_at_former_employer,acme.com,Staff Engineer,jane@oldco.example,verified

Note 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

ErrorCauseFix
401Missing, wrong or revoked keyCheck x-api-key and that $LEADOCEAN_API_KEY is exported in the cron shell.
400Two identifiers sent, or noneSend exactly one of linkedin_url, email, phone or person_id.
404Nobody matchedFree. Keep the old row and mark it unrefreshed.
429Above 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.
402Free plan's 1,000 records spentThey do not reset. Upgrade to continue (limits, October 2026).
Export refusedOver 50,000 rows a request or 500,000 a daySplit 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 →