TL;DR
- Time: about 45 minutes to build, then one cron line.
- Cost: about 400 records for the first snapshot of a 200-person watchlist (200 search rows, 200 enrichments), then about 200 per monthly check plus one per company. Sizing calls are free.
- Output: a list of people whose current role changed and companies whose record changed, ready to push to Slack or a CRM.
You can monitor job changes with LeadOcean by saving a snapshot of each person and company, re-pulling it on a schedule, and diffing the two. LeadOcean does not push webhooks. The dataset refreshes monthly, so a monthly diff is the honest cadence.
If you need hourly alerts delivered to a webhook, Crustdata's Watcher page describes exactly that, with checks as often as hourly (Crustdata Watcher, October 2026). If a monthly signal is enough, this recipe costs a few hundred records and no new vendor.
Prerequisites
- A LeadOcean API key in
$LEADOCEAN_API_KEY. The Free plan gives 1,000 records for life, no card, 5 requests a second. - Python 3.9 or later with
requests. - A watchlist: up to a few hundred company domains and the roles you care about.
- Somewhere to run a monthly job (cron, GitHub Actions, n8n).
Step 1: Size the watchlist for free
Count before you spend. Counts cost zero records, so run them as often as you like. The free MCP tool leadocean_count_leads takes the same filters as search. By default it counts mailable people only: verified, catch_all_valid or catch_all.
curl -s -X POST "https://api.leadocean.io/v1/people/search?count=true" \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"jobLevel": ["VP"],
"jobFunction": ["Sales & Business Development"],
"country": ["CA"],
"limit": 1
}'count=true goes in the query string, not the JSON body, and meta.total caps at 100,000. For company lists use GET /v1/companies/count, which is also free.
Step 2: Pull the watchlist and keep the person IDs
Search by company domain and role. The body is flat JSON, filters are arrays, limit runs 1 to 100, and you page with cursor. Each person returned costs one record.
import os, json, requests
API = "https://api.leadocean.io"
H = {"x-api-key": os.environ["LEADOCEAN_API_KEY"], "Content-Type": "application/json"}
def watchlist(domains, titles):
ids, cursor = [], None
while True:
body = {"domain": domains, "title": titles, "limit": 100}
if cursor:
body["cursor"] = cursor
r = requests.post(f"{API}/v1/people/search", headers=H, json=body)
r.raise_for_status()
j = r.json()
ids += [p["meta"]["source_ids"]["person_id"] for p in j["data"]]
cursor = j.get("meta", {}).get("nextCursor")
if not cursor:
return ids
ids = watchlist(["acme.com", "globex.com"], ["VP Sales", "Head of Sales"])
json.dump(ids, open("watchlist.json", "w"))Each row carries its ID at meta.source_ids.person_id. Run this once. Save watchlist.json. Every later run reuses the IDs.
Step 3: Snapshot each person's current role
Enrich by person_id. It returns the current role in contact_current_experiences[], plus resume_data with past roles when held. An enrichment that finds nothing is a 404 and costs nothing.
def current_role(pid):
r = requests.post(f"{API}/v1/people/enrich", headers=H, json={"person_id": pid})
if r.status_code == 404:
return None
r.raise_for_status()
return r.json()["data"]["contact_data"].get("contact_current_experiences", [])
snapshot = {pid: current_role(pid) for pid in ids}
json.dump(snapshot, open("people_snapshot.json", "w"))Keep the loop under your plan's rate limit: 5 requests a second on Free, 200 on Agency. Past employers carry no company_id yet, so match them by name, not ID.
Step 4: Snapshot the companies
Do the same for the companies behind the watchlist. Company enrich takes a domain (or a linkedin_url) in the JSON body. Keep the record's company_data and company_metrics blocks, then compare them on the next run.
def company(domain):
r = requests.post(f"{API}/v1/companies/enrich", headers=H, json={"domain": domain})
if r.status_code == 404:
return None
r.raise_for_status()
d = r.json()["data"]
return {"company_data": d.get("company_data"), "company_metrics": d.get("company_metrics")}
companies = {d: company(d) for d in ["acme.com", "globex.com"]}
json.dump(companies, open("company_snapshot.json", "w"))Leave the record's meta block out of the snapshot. It describes the lookup, not the company, so keeping it makes every record look changed.
Step 5: Diff on a schedule
Run the same pulls monthly and compare. This is the whole monitor.
def diff(old, new):
out = []
for key, now in new.items():
before = old.get(key)
if before != now:
out.append({"key": key, "before": before, "after": now})
return out
old_p = json.load(open("people_snapshot.json"))
new_p = {pid: current_role(pid) for pid in ids}
moves = diff(old_p, new_p)
old_c = json.load(open("company_snapshot.json"))
new_c = {d: company(d) for d in old_c}
changed = diff(old_c, new_c)
print(json.dumps({"job_changes": moves, "company_changes": changed}, indent=2))
json.dump(new_p, open("people_snapshot.json", "w"))
json.dump(new_c, open("company_snapshot.json", "w"))Schedule it the day after the monthly refresh:
0 6 3 * * cd /opt/monitor && python3 monitor.py >> monitor.log 2>&1A person who moved shows a different contact_current_experiences[]. When the new employer is a target account, pass the same person_id to GET /v2/people/email/work for a fresh address. That call costs one record, including a miss.
What you get
A free count run on 2026-10-21 for the Step 1 filters (jobLevel VP, jobFunction Sales & Business Development, country CA, mailable by default: verified, catch_all_valid or catch_all):
{
"total": 5574,
"totalIsExact": true,
"totalAll": 18800,
"distinctCompanies": 3873,
"credits": 0
}The script prints this shape. The values are placeholders (Acme, Globex, a made-up ID), not a real record:
{
"job_changes": [
{
"key": "1000000001",
"before": [{"job_title": "VP Sales", "company_name": "Acme"}],
"after": [{"job_title": "CRO", "company_name": "Globex"}]
}
],
"company_changes": []
}An empty company_changes list is normal. Most companies do not change in a month.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing or wrong x-api-key | Check $LEADOCEAN_API_KEY is exported in the job's environment. |
| 429 | Requests above the plan rate limit (5 a second on Free, 200 on Agency) | Honor Retry-After and sleep between calls. |
| 402 | The plan's records are spent | Free is 1,000 records for life. Move to Solo ($97 a month) or shrink the watchlist. |
| Empty search results | A filter value that is not an exact enum string | Check values with leadocean_list_enum_values, for example VP, not vp, in jobLevel. |
| 404 on enrich | We hold nothing for that person_id | Skip it. It costs nothing. |
meta.depthCapped is true | One search pages to 10,000 rows | Narrow the filters, for example one domain batch per search. |
FAQ
Does LeadOcean send job change alerts or webhooks?
No. You pull and diff. The dataset is refreshed monthly, so a monthly run catches every change we hold. For hourly alerts, look at a watcher product.
How many records does monitoring cost?
One record per person or company returned. Watching 200 people costs about 200 records a run. Counts, 404s and empty pages are free. Paid plans run on a fair-use pace, not credits. See pricing.
Can I see where someone worked before?
Yes. Person enrich returns resume_data with past roles, education and certifications, each when held. That helps you spot a champion who moved into a target account.
How do I track headcount growth at a company?
Diff the company record month to month. The company headcount enrichment page covers the field.
Where do I go next?
Read the company enrichment API guide for the full record shape, and best company data APIs to compare vendors.
Start monitoring job changes with a free API key
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →