Real-time enrichment means one API call per lead, made when the lead arrives, with the answer back before your next step runs. The loop below does that with one request per person.
"Real time" here means on request. The dataset is refreshed monthly and each record carries its own fetched_at date. Read it before you trust a field that changes often, such as a job title.
TL;DR
- Enrich one person with
POST /v1/people/enrich, using exactly one oflinkedin_url,email,phoneorperson_id. - It takes about 30 minutes to wire up. Each enriched lead spends 1 record. Sizing, misses and the 404 case spend 0.
- Branch on
contact_data.email_statusbefore anything gets sent. No refund exists for bounced addresses.
Prerequisites
- A LeadOcean account on the Free plan: 1,000 records for life, no card, 5 requests a second. Register at app.leadocean.io.
- An API key from the app, stored as
$LEADOCEAN_API_KEY. curlandjq, plus Python 3 withrequestsfor Step 4.- A source of leads: a signup form, a webhook or a CSV. Anything that gives you an email or a LinkedIn URL.
Step 1: Size your audience for free
Count before you spend. A count costs 0 records and tells you whether the ICP exists in the data. Enum values are exact strings, so list them first.
curl -s "https://api.leadocean.io/v1/enums/job_level" \
-H "x-api-key: $LEADOCEAN_API_KEY" | jq -c '.'Then count with the search endpoint. Send count=true as a query parameter, not in the JSON body, with limit 1.
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": ["US"],
"limit": 1
}' | jq '.meta | {total, totalIsExact, countOnly}'meta.total is capped at 100,000. Inside an agent, the free leadocean_count_leads MCP tool gives the exact figure with no cap.
Count first for one reason: it stops you buying a list that does not exist. A VP-level sales audience in the US is large. A narrow title in a small country may be a few hundred people, and you want to know that before you build a flow around it.
Step 2: Enrich one lead by email
Send one identifier. Here it is an email from a signup form. Set reveal_email to true to get every address and phone we hold. It costs no extra record.
curl -s -X POST "https://api.leadocean.io/v1/people/enrich" \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "content-type: application/json" \
-d '{
"email": "jane.doe@acme.com",
"reveal_email": true
}' > lead.json
jq '{status: .data.contact_data.email_status, role: .data.contact_data.contact_current_experiences[0]}' lead.jsonA hit spends 1 record. A person we hold nothing for is a 404 and spends nothing, so a bad email on your form never costs you.
Step 3: Branch on the result
Do not treat every 200 as sendable. Read email_status and route the lead. Verified and catch-all-valid addresses can go to a sequence. Everything else goes to a different path.
STATUS=$(jq -r '.data.contact_data.email_status // "none"' lead.json)
case "$STATUS" in
verified|catch_all_valid) echo "route: send" ;;
catch_all) echo "route: send, low volume" ;;
*) echo "route: hold, find another contact" ;;
esacThe mailable default in the LeadOcean tools is verified, catch_all_valid and catch_all. Other values include risky, unknown, invalid, role and disposable.
Step 4: Find the person when you only have a company
A signup often gives you a work domain and no person. There is no name-plus-domain lookup. Search by domain first, then enrich by person_id.
import os, requests
BASE = "https://api.leadocean.io"
H = {"x-api-key": os.environ["LEADOCEAN_API_KEY"], "content-type": "application/json"}
r = requests.get(f"{BASE}/v1/companies/acme.com/people", headers=H, params={"limit": 5})
rows = r.json()["data"]
person_id = rows[0]["meta"]["source_ids"]["person_id"]
e = requests.post(f"{BASE}/v1/people/enrich", headers=H,
json={"person_id": person_id, "reveal_email": True})
print(e.status_code, e.json()["data"]["contact_data"].get("email_status"))The people search call spends 1 record per person returned. The enrich call spends 1 more.
Pick the right row before you enrich. The search row spends a record, so a limit of 5 costs 5. Filter by title or seniority on the people search endpoint when you want one role.
Step 5: Handle rate limits and misses in a loop
For a queue of leads, respect the plan's rate limit and read Retry-After on a 429. Free is 5 requests a second. Agency is 200.
import time
def enrich(email):
for attempt in range(5):
r = requests.post(f"{BASE}/v1/people/enrich", headers=H,
json={"email": email, "reveal_email": True})
if r.status_code == 200:
return r.json()["data"]
if r.status_code == 404:
return None
if r.status_code == 429:
time.sleep(float(r.headers.get("Retry-After", 1)))
continue
r.raise_for_status()
return NoneRun the loop from a queue worker, not from the signup request itself. The signup page should return at once, and the worker enriches in the background a second later. That is still real time for a sales team.
A 402 means the records are spent. On Free that is a hard stop. Paid plans have a fair-use pace instead of credits, so the key slows down and meta.notice names the window.
A common mistake is to retry a 404. A 404 on a clean email means the person is not in the data. It will not change on a retry, so drop the lead to a manual path or enrich by linkedin_url instead.
Step 6: Use career history to score the lead
Since October 2026 the enrich response carries resume_data: past roles, education, certifications and awards, each when held. Past employers have no company_id yet, so match on the company name.
jq '.data.resume_data.experiences[:3]' lead.jsonA first-time VP with two earlier sales leadership roles scores differently from a first-time manager. Score on what the fields hold, and skip any field that is absent.
Keep the score simple at first. Three signals are enough: current job level, years in the current role and whether a past role was in the same function. Tune the weights only after you see which leads reply.
Step 7: Cache the answer and skip repeats
Store the response against the lead id the first time you enrich. A second call for the same person spends another record, so check your own table first.
SELECT 1 FROM enriched_leads WHERE email = 'jane.doe@acme.com' AND fetched_at > now() - interval '30 days';The dataset refreshes monthly, so a 30-day cache loses nothing. Re-enrich only when a lead reactivates after that window or when a rep reports a bounce.
What you get
Step 1 ran through the free leadocean_count_leads tool on 2026-10-21. The filters were jobLevel VP, jobFunction Sales & Business Development and country US.
{"total":129494,"totalIsExact":true,"totalAll":350517,"distinctCompanies":58121,"credits":0}That is a mailable count: the tool defaults to verified, catch_all_valid and catch_all addresses. totalAll is the same filters with no email condition. The call spent 0 records.
An enrich response has this shape. Response shape from the API reference (placeholder values):
{
"success": true,
"data": {
"profile_data": { "profile_full_name": "Jane Doe" },
"contact_data": {
"has_email": true,
"has_phone": true,
"email_status": "verified",
"email_type": "work",
"contact_emails": [
{ "email": "jane.doe@acme.com", "type": "work", "status": "verified", "priority": 1 }
],
"contact_phones": [
{ "phone": "+1 415 555 0133", "type": "mobile", "priority": 1 }
],
"contact_current_experiences": [ { "job_title": "VP Sales", "company_name": "Acme" } ]
},
"resume_data": { "experiences": [], "educations": [] }
},
"meta": { "credits": 1 }
}Write contact_current_experiences[0] to your CRM as the current role. Write email_status next to the address so reps see the verdict, not just the address.
The email and phone arrays appear only when reveal_email is true. has_email and has_phone answer either way.
Cost of the whole loop
The loop in this guide spends 1 record per lead that is found. A test of three leads spends 3 records, and a miss spends 0. On Free you can enrich up to 1,000 people, one time. At 10,000 leads a month a team will want Solo at $97 a month, or Agency at $497 a month for teams and 200 requests a second.
Bulk work does not need a loop. Past a few thousand leads, create a CSV with POST /v1/exports, up to 50,000 rows a request and 500,000 a day. Bulk CSV export is not on Free.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing, wrong or revoked key | Re-copy the key from app.leadocean.io. The header is x-api-key. |
| 429 | Above the plan's rate limit (5 a second on Free, 200 on Agency) | Wait for Retry-After, then retry. Queue requests at the plan's rate. |
| 402 | Fewer records left than the request needs | Check GET /v1/account, or move to Solo or Agency. |
| 404 on enrich | Nobody matched the identifier, or no enrichable identifier is held | Costs nothing. Try linkedin_url or search by domain, then enrich by person_id. |
| Empty search results | A filter value outside the list, such as Vice President | Use VP. Check GET /v1/enums/job_level. |
| 400 on enrich | More than one identifier in the body | Send exactly one of linkedin_url, email, phone or person_id. |
FAQ
Is this real-time data?
It is on-request enrichment, not a live crawl. The dataset is refreshed monthly and each record carries its own fetched_at date. Check that date for fast-changing fields.
What does one enrichment cost?
One record when a person is found. A 404, a count and an empty page cost nothing. Free gives you 1,000 records for life. Solo is $97 a month and Agency is $497 a month, with a fair-use pace instead of credits. See pricing.
Can I enrich from a name and a company domain?
Not in one call. Search by domain, pick the person, then enrich by person_id. The people data API page shows the endpoints side by side.
Can an AI agent do this instead of code?
Yes. The hosted MCP server has 13 tools, including free counting. The same enrich logic works in Claude, as in the recruiting tools guide. For post-level signals, see the social posts API guide.
Enrich your first lead free, in about 30 minutes
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →