Programmatic CRM enrichment is a loop: find the people, enrich each one by id, write the fields back. This guide builds that loop against the LeadOcean REST API with real requests. The same pattern works with any CRM that has an update endpoint.
TL;DR
- Four calls do the job: a free count, a search, a person enrich and your CRM's upsert.
- It takes about 45 minutes, and the example spends about 20 of your 1,000 free records.
- Counting is free, and an enrichment that finds nobody costs nothing, so you can size and test before you spend.
Prerequisites
- A LeadOcean API key from app.leadocean.io. The Free plan works and needs no card.
- The key in your shell:
export LEADOCEAN_API_KEY=your_key_here. - Python 3.9 or later with
requests, or any HTTP client. - Write access to your CRM's API (a token for a contact upsert). The CRM call below is a placeholder you replace.
- Free runs at 5 requests a second. Keep the loop under that, or move to a plan with a higher limit.
Step 1: Size the list before you spend anything
Counting is free, so start there. Send count=true as a query parameter with limit=1. The JSON body rejects unknown fields, so the flag does not go in the body.
curl -s -G "https://api.leadocean.io/v1/people/search" \
-H "x-api-key: $LEADOCEAN_API_KEY" \
--data-urlencode "count=true" \
--data-urlencode "limit=1" \
--data-urlencode "jobLevel=VP" \
--data-urlencode "jobFunction=Sales & Business Development" \
--data-urlencode "country=US" \
--data-urlencode "employeeRange=51-200"meta.total is the match count, capped at 100,000. The data array comes back empty with meta.countOnly true. If you work from an AI client, the MCP tool leadocean_count_leads does the same call. For company lists use GET /v1/companies/count, which is also free.
Step 2: Pull the rows with a search
Search returns one record per person. The body is flat JSON, filters are arrays of strings, and limit runs from 1 to 100. Enum values are exact strings, so check them with leadocean_list_enum_values or GET /v1/enums first.
curl -s -X POST "https://api.leadocean.io/v1/people/search" \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"jobLevel": ["VP"],
"jobFunction": ["Sales & Business Development"],
"country": ["US"],
"employeeRange": ["51-200"],
"limit": 10
}'Each row carries an id at meta.source_ids.person_id. Keep it. Search publishes email flags, not addresses, and about a fifth of findable people have no LinkedIn URL, so the id is what lets you enrich every row.
To page, pass back meta.nextCursor unchanged as cursor. One search pages to 10,000 rows. On the last page meta.depthCapped is true and there is no cursor, so narrow the filters and run several smaller searches.
Step 3: Enrich each person by person_id
POST /v1/people/enrich takes exactly one of linkedin_url, email, phone or person_id. It costs one record. Add reveal_email to get the addresses and phones we hold, at no extra charge.
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", "reveal_email": true}'There is no name plus domain lookup. If your CRM only holds a name and a company, search by domain first, then enrich the id. If the CRM already holds an email or a LinkedIn URL, skip the search and enrich on that directly.
Since October 2026 the response also includes resume_data: past roles, education, certifications and awards. Past employers carry no company_id yet, so match them on name.
Step 4: Write the fields back to your CRM
This loop reads contacts from a search, enriches each, and upserts. Swap upsert_to_crm for your CRM's client. It stays under the 5 requests a second Free limit.
import os, time, requests
BASE = "https://api.leadocean.io"
H = {"x-api-key": os.environ["LEADOCEAN_API_KEY"]}
def search(cursor=None):
body = {"jobLevel": ["VP"], "jobFunction": ["Sales & Business Development"],
"country": ["US"], "employeeRange": ["51-200"], "limit": 10}
if cursor:
body["cursor"] = cursor
r = requests.post(f"{BASE}/v1/people/search", headers=H, json=body)
r.raise_for_status()
return r.json()
def enrich(person_id):
r = requests.post(f"{BASE}/v1/people/enrich", headers=H,
json={"person_id": person_id, "reveal_email": True})
if r.status_code == 404: # nobody matched, costs nothing
return None
r.raise_for_status()
return r.json()
def upsert_to_crm(person):
... # your CRM client here, keyed on email or LinkedIn URL
page = search()
for row in page["data"]:
person = enrich(row["meta"]["source_ids"]["person_id"])
if person:
upsert_to_crm(person)
time.sleep(0.25) # 4 a second, under the Free limitMap contact_data.email_status to a CRM field and filter on it before any send. Bounces are not refunded, so read the status first. For a few thousand rows or more, skip the loop and use POST /v1/exports: up to 50,000 rows a request and 500,000 a day, with bulk CSV on paid plans.
What you get
Step 1 on 21 October 2026, run with the free leadocean_count_leads tool for US VPs of Sales at companies with 51 to 200 employees:
{
"total": 18567,
"totalIsExact": true,
"totalAll": 60456,
"distinctCompanies": 12192,
"credits": 0
}total counts people whose best email is verified or catch-all. totalAll is the same filters with no email condition. It is a mailable-email count, not a count of everyone.
Response shape from the API reference, placeholder values, for the enrich call:
{
"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": "+14155550133", "type": "direct", "priority": 1 }],
"contact_current_experiences": [{ "job_title": "VP Sales", "company_name": "Acme" }]
},
"resume_data": { "experiences": [], "educations": [] }
}
}In the loop, enrich() returns this whole object, so the person sits under data.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing or wrong x-api-key header | Check $LEADOCEAN_API_KEY is exported and sent as x-api-key |
| 429 | Above your plan's rate limit (5 a second on Free, 200 on Agency) | Honour Retry-After, or add a sleep to the loop |
| 402 | Records are spent (Free is 1,000 for life) | Check GET /v1/account, or move to a paid plan |
Empty data on a search | A filter value that is not an exact enum string, such as VP of Sales for jobLevel | Look up the value with GET /v1/enums and resend |
| 404 on enrich | Nobody matched that identifier | Not an error to retry. It costs nothing, so skip the row |
No nextCursor before you expected | The 10,000 row depth cap (meta.depthCapped true) | Split the list into narrower searches |
FAQ
How many records does CRM enrichment cost?
One record per person you receive. A search page of 10 is 10 records, and each enrichment is one more. Counts, 404s and empty pages are free.
Can I enrich by name and company domain?
Not directly. Search by domain to get person ids, then enrich by person_id. If the CRM already holds an email or LinkedIn URL, enrich on that.
Does it work with Salesforce, HubSpot or a spreadsheet?
Yes, if it has an update API or an import. LeadOcean returns JSON, and the CRM side is your upsert_to_crm. For no-code flows see the Clay integration or n8n.
How fresh is the data?
The dataset is refreshed monthly, and each record carries its own fetched_at date. Re-enrich on a schedule, not on every page view.
Is there a CRM inside LeadOcean?
No. LeadOcean is the data layer: API, MCP server and an exports page in the app. Your CRM stays where it is.
More on this: CRM enrichment tools compared, the CRM enrichment use case, the company enrichment API and pricing. Back to the blog hub.
Enrich your CRM from one API call
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →