You cannot read intent from a profile, but you can rank employed people by one proxy for a coming move: how long they have stayed compared with their own history. This guide does that with two LeadOcean calls and a short script. It gives you a ranked list, not a verdict.
TL;DR
- Search a cohort, enrich each person for
resume_data, then score current tenure against their average past tenure. - It takes about 30 minutes and spends 1 record per person returned by search and 1 per person enriched. A 25-person pilot is 50 records, inside the Free plan's 1,000.
- The score is a heuristic. Use it to order outreach, never to claim someone is looking.
Prerequisites
- A LeadOcean API key from app.leadocean.io, exported as
$LEADOCEAN_API_KEY. Free works: 1,000 records for life, no card, 5 requests a second. curland Python 3.- A role you recruit for. The examples use data engineers at mid-size companies in the US.
Step 1: Size the cohort for free
Count first. A count spends nothing, and a cohort over 10,000 rows cannot be paged in one search (meta.depthCapped), so narrow it before you pull anything.
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" \
-d '{
"title": ["Data Engineer"],
"country": ["US"],
"jobLevel": ["Staff"],
"employeeRange": ["1001-5000"]
}'count=true goes in the query string, not the body. meta.total is capped at 100,000. In the MCP server the same check is leadocean_count_leads, which is also free. Enum values are exact strings, so check GET /v1/enums or leadocean_list_enum_values before you guess.
Step 2: Pull a page of people
Search returns the current role and start date on each row. Each person returned is one record.
curl -s -X POST "https://api.leadocean.io/v1/people/search" \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "content-type: application/json" \
-d '{
"title": ["Data Engineer"],
"country": ["US"],
"jobLevel": ["Staff"],
"employeeRange": ["1001-5000"],
"limit": 25
}' > search.jsonThe body is flat JSON and every filter is an array. limit runs 1 to 100. Page on with meta.nextCursor. Each row carries meta.source_ids.person_id; keep it, because about one person in five has no LinkedIn URL and that id is the only way to enrich them.
Step 3: Enrich for career history
Enrichment returns resume_data, which holds past roles in experiences[]: job_title, company_name, start_date, end_date and job_seniority.job_level. It has been in the response since 2026-10-17. Past employers come back by name only, with no company_id.
curl -s -X POST "https://api.leadocean.io/v1/people/enrich" \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "content-type: application/json" \
-d '{"person_id": "1234567890"}' > person.jsonSend exactly one of linkedin_url, email, phone or person_id. A person we hold nothing for is a 404 and costs nothing. Leave reveal_email off here: it is not needed to score, and you can reveal contact data later for only the people you shortlist.
Step 4: Score tenure against their own pattern
The signal is relative. Take someone who stayed about 2.5 years in each of three roles and is now 3.4 years into a fourth. They are further past their pattern than someone at 3.4 years who has always stayed 8. Compute that ratio.
The current role sits in contact_data.contact_current_experiences with its start_date. Past roles are in resume_data.experiences, and the current role is not repeated there.
import json, sys
from datetime import date
START, END = "start_date", "end_date"
def years(a, b):
return (b - a).days / 365.25
def parse(s):
return date.fromisoformat(s[:10]) if s else None
def score(person):
data = person["data"]
now = data["contact_data"]["contact_current_experiences"][0]
cur_start = parse(now.get(START))
if not cur_start:
return None
past = []
for e in data.get("resume_data", {}).get("experiences", []):
a, b = parse(e.get(START)), parse(e.get(END))
if a and b and b > a:
past.append(years(a, b))
if len(past) < 2:
return None # too little history to compare
avg = sum(past) / len(past)
tenure = years(cur_start, date.today())
return round(tenure / avg, 2), round(tenure, 1), round(avg, 1)
p = json.load(open(sys.argv[1]))
print(score(p))A ratio near or above 1.0 means the person is at or past their usual stay. Under 0.5 they have only just arrived. Requiring two past roles stops one short job from distorting the average.
Step 5: Rank, then add one more check
Run the script over every enriched file and sort descending by ratio. Then drop anyone whose still-at-company status on the record suggests the role is stale, since the dataset refreshes monthly and a record can lag a real move by weeks.
Two further signals fit the same data. A title that has not risen across the last two employers is a lateral pattern. A long tenure with no level change shows as an unchanged job_seniority.job_level across resume_data.experiences. Add them as extra columns, not as extra weight, until you have checked them against replies from your own outreach.
What you get
Response shape from the API reference, with placeholder values. This is not a real person.
{
"success": true,
"data": {
"profile_data": { "profile_full_name": "Jane Doe" },
"contact_data": {
"has_email": true,
"email_status": "verified",
"contact_current_experiences": [
{ "job_title": "Staff Data Engineer", "company_name": "Acme",
"company_domain": "acme.com", "start_date": "2023-04-01" }
]
},
"resume_data": {
"experiences": [
{ "job_title": "Data Engineer", "company_name": "Example Corp",
"start_date": "2019-06-01", "end_date": "2021-09-01" },
{ "job_title": "Senior Data Engineer", "company_name": "Sample Ltd",
"start_date": "2021-10-01", "end_date": "2023-03-01" }
]
}
},
"meta": { "credits": 1 }
}For this placeholder record the script prints a tuple of three numbers: the ratio, current tenure in years and average past tenure in years. Field names follow the enrichment docs.
The Step 1 cohort, counted with the free leadocean_count_leads tool on 2026-10-21:
filters: title "Data Engineer", country US, jobLevel Staff, employeeRange 1001-5000
total (no email condition): 12,454
mailable (verified, catch_all_valid, catch_all): 5,254
distinct employers: 1,941At 12,454 the cohort is over the 10,000-row depth of one search. Split it, for example by city or a narrower title, before you page through it.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing or wrong x-api-key | Check $LEADOCEAN_API_KEY is exported in this shell |
| 429 | Above the plan's rate limit (5 a second on Free, 200 on Agency) | Honour Retry-After; enrich in a slow loop |
| 402 | Out of records | Check GET /v1/account; the Free 1,000 is one-off |
| Zero rows | A filter value is not an exact enum string | List values with GET /v1/enums |
| 404 on enrich | No record we can enrich by that id | Costs nothing; skip the person |
Search stops, depthCapped true | One search pages to 10,000 rows | Narrow filters and run several searches |
FAQ
Is this the same as an open-to-work signal?
No. It is an inference from history, and it can be wrong. Treat a high ratio as a reason to write first, not as proof.
How many records does a pilot cost?
A page of 25 people is 25 records. Enriching all 25 is 25 more, so 50. Misses on enrich cost nothing.
Can I filter by tenure directly?
Not as a filter in the documented search parameters. Pull the cohort, enrich, then compute tenure yourself as above.
Can I run this from an AI agent?
Yes. The MCP server at https://api.leadocean.io/mcp exposes 13 tools, including leadocean_get_lead for enrichment and leadocean_count_leads for free sizing.
Does this replace a recruiting CRM?
No. LeadOcean returns data. It has no sequencer, dialer or CRM.
Related reading: how to identify and act on buying signals, job change signals, buying signal and pricing.
Rank your first 25 candidates by tenure signal, free
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →