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

How-to guide

How to Build an Auto-Updating Candidate Database (With a People API)

Size a pool for free, seed it, refresh it by person_id and add new people on a schedule. Short scripts, real requests, record costs.

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

An auto-updating candidate database is a table of people plus a job that re-checks each row on a schedule and adds new people who match your profile. LeadOcean gives you the three calls it needs: search to find people, enrich to refresh one, export to load many. Everything below runs from cron.

This guide is for engineers who own the table. It uses placeholders such as Jane Doe and acme.com.

TL;DR

  • Build four pieces: a sizing count, a seed load, a monthly refresh by person_id, and a weekly sync that adds new people on a cursor.
  • Time: about 45 minutes to set up. The seed and refresh scripts are under 40 lines each.
  • Records: 1 per person returned. Counts, 404s and empty pages cost nothing. Free covers 1,000 records for life, enough to prove the loop on a small slice.

Prerequisites

  • A LeadOcean key in $LEADOCEAN_API_KEY. Free needs no card and includes search and enrich (sign up).
  • Python 3 with requests, and SQLite (or any table you control).
  • A filter profile: the people you want in the database. The examples use Staff engineers in Great Britain.
  • Bulk CSV export is not on Free. On Free, seed with paged search instead (Step 2).

Step 1: Size the pool before you spend a record

Count first. A count is free and returns an exact total up to 100,000 (limits, October 2026).

bash
curl -s -X POST "https://api.leadocean.io/v1/people/search?count=true&limit=1" \
  -H "x-api-key: $LEADOCEAN_API_KEY" -H "content-type: application/json" \
  -d '{"jobFunction":["Engineering"],"jobLevel":["Staff"],"country":["GB"]}' \
  | jq '.meta | {total, distinctCompanies, countOnly}'

count=true goes in the query string, not the body, which rejects unknown fields. Enum values are exact strings: Staff, not staff, and GB, not United Kingdom. Resolve them with GET /v1/enums or the leadocean_list_enum_values MCP tool.

One search pages to 10,000 rows. If the count is larger, narrow the filters into slices (by country, level or industry) and seed each slice on its own.

Step 2: Seed the table

On Solo or above, one export loads up to 50,000 rows a request and 500,000 a day (exports, October 2026). Every row costs one record.

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-seed","filters":{"jobFunction":["Engineering"],"jobLevel":["Staff"],"country":["GB"]},"limit":5000,"preset":"search"}'

Poll GET /v1/exports/{id}, then download from /v1/exports/{id}/download. On Free, page through search instead. Search takes a flat JSON body, up to 100 rows a page, and a cursor.

python
import os, sqlite3, requests

H = {"x-api-key": os.environ["LEADOCEAN_API_KEY"], "content-type": "application/json"}
db = sqlite3.connect("candidates.db")
db.execute("""create table if not exists cand(
  person_id text primary key, name text, company_domain text,
  job_title text, email text, email_status text, refreshed_at text)""")

body = {"jobFunction": ["Engineering"], "jobLevel": ["Staff"],
        "country": ["GB"], "limit": 100}
spent, cursor = 0, None
while spent < 400:                      # leaves Free records to enrich these rows
    if cursor: body["cursor"] = cursor
    r = requests.post("https://api.leadocean.io/v1/people/search", headers=H, json=body, timeout=30)
    r.raise_for_status()
    page = r.json()
    for p in page["data"]:
        pid = p["meta"]["source_ids"]["person_id"]
        db.execute("insert or ignore into cand(person_id) values (?)", (pid,))
    spent += len(page["data"])
    cursor = page["meta"].get("nextCursor")
    if not cursor: break
db.commit()
print("records spent:", spent)

The key is person_id, at meta.source_ids.person_id on every search row. It is the handle you enrich by, so store it for every row. On Free, 400 seeded rows plus 400 enrichments stay inside the 1,000 records.

Step 3: Enrich each row by person_id

Send exactly one of linkedin_url, email, phone or person_id per call. Set reveal_email to get the address.

python
import time, datetime

def enrich(pid):
    while True:
        r = requests.post("https://api.leadocean.io/v1/people/enrich", headers=H,
                          json={"person_id": pid, "reveal_email": True}, timeout=30)
        if r.status_code == 404: return None          # nobody matched, costs nothing
        if r.status_code == 429:
            if r.headers.get("X-Quota-State") == "paced":
                raise SystemExit("Fair-use pace reached; resets " + r.headers.get("X-Quota-Reset", "later"))
            time.sleep(int(r.headers.get("Retry-After", "1"))); continue
        if r.status_code == 402: raise SystemExit("Free records spent")
        r.raise_for_status()
        return r.json()["data"]["contact_data"]

def store(pid, c):
    exp = (c.get("contact_current_experiences") or [{}])[0]
    mail = (c.get("contact_emails") or [{}])[0]
    db.execute("""update cand set company_domain=?, job_title=?, email=?, email_status=?, refreshed_at=?
                  where person_id=?""",
               (exp.get("company_domain"), exp.get("job_title"), mail.get("email"),
                mail.get("status"), datetime.date.today().isoformat(), pid))

Pace the loop to your plan: 5 requests a second on Free, 50 on Solo, 200 on Agency.

Step 4: Detect what changed

The refresh is a diff, not an overwrite. Compare the new employer and title to the stored row before store runs, and write a history row when they differ.

python
db.execute("create table if not exists history(person_id text, seen text, company_domain text, job_title text)")

def refresh_all():
    for (pid, old_co, old_title) in db.execute(
            "select person_id, company_domain, job_title from cand").fetchall():
        c = enrich(pid)
        if c is None: continue
        new = (c.get("contact_current_experiences") or [{}])[0]
        if old_co and new.get("company_domain") != old_co:
            db.execute("insert into history values (?,?,?,?)",
                       (pid, datetime.date.today().isoformat(), old_co, old_title))
        store(pid, c)
        time.sleep(0.2)               # 0.02 on Solo
    db.commit()

A 404 means LeadOcean holds nothing for that person. Keep the old row. Do not delete on a miss.

Since October 2026, enrich also returns resume_data: past roles, education and certifications. Past employers carry no company_id yet, so match them by name (enrich docs, October 2026).

Step 5: Add new people on a schedule

Rerun the Step 2 search weekly. The insert or ignore skips people you already hold, so only new person_ids enter the table. Enrich the new rows with refresh_all, or a variant that selects where refreshed_at is null.

One caution: a search page spends a record for every person returned, including people already in your table. To avoid re-buying, narrow the filters each week, for example one new country or level per run. Counts stay free, so check the size first.

Step 6: Schedule it

The dataset is refreshed monthly and each record carries its own fetched_at date. A monthly refresh matches that cadence. A weekly run mostly re-buys unchanged data.

Monthly refresh on the 1st at 06:00, new-people sync every Monday at 07:00:

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

Before a big run, read GET /v1/account. It is free and shows how much of your pace window is used.

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 mailable people (verified, catch_all_valid or catch_all), the tool's default. totalAll is the same filters with no email condition. Credits spent: 0.

Response shape from the API reference, with placeholder values:

json
{
  "success": true,
  "data": {
    "contact_data": {
      "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 }
}

A placeholder history row after a move: c-0042 | 2026-11-01 | oldco.example | Senior Engineer.

Troubleshooting

ErrorCauseFix
401Missing, wrong or revoked keyCheck x-api-key and that $LEADOCEAN_API_KEY is exported in the cron shell.
429Above the plan's rate (5 a second on Free, 200 on Agency)Honour Retry-After. If X-Quota-State is paced, stop until X-Quota-Reset.
402Free plan's 1,000 records spentThey do not reset. Upgrade to continue.
Empty resultsEnum value not an exact stringCheck GET /v1/enums for the exact value, such as Staff or GB.
Search stops with meta.depthCappedOne search pages to 10,000 rowsSplit the filters into slices and seed each one.
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 the database update?

Monthly for the full refresh, because the dataset is refreshed monthly. Add a weekly sync for new people. Refresh sooner only for candidates you are about to contact.

What does it cost to keep 10,000 candidates fresh?

At most 10,000 records per monthly refresh, fewer once 404s drop out. That is above Free and inside the Solo pace of 10k enrichments every 5 hours (pricing, October 2026).

Can I use the MCP server instead of scripts?

Yes, for exploration. The 13 MCP tools include free leadocean_count_leads and leadocean_count_companies. For a scheduled job, plain REST from cron is simpler. See refreshing a candidate database for the diff in detail.

Where do I go next?

Layer filters with Claude in multi-layer candidate filtering. For the wider data picture, read B2B data building, or browse the blog. Plans are on pricing.

Build your candidate database on a free key

Free to start. No credit card. 1,000 records to spend whenever you like.

Get your free API key →