Personalization that works in B2B is one true, specific line about the person or their company. This guide builds that line from data fields, not from guesswork, in about 30 minutes.
TL;DR
- You size an audience for free, pull 25 people, enrich 10, and generate one opener each.
- It takes about 30 minutes and spends about 35 LeadOcean records. The Free plan (1,000 records for life) covers it.
- The rule: a field either produces a sentence or the message falls back to a plain one. Never invent the line.
Prerequisites
- A LeadOcean API key from app.leadocean.io, exported as
$LEADOCEAN_API_KEY. The Free plan runs at 5 requests a second, which is enough here. curlandjq, plus Python 3 for step 4.- A target in mind. This guide uses VPs of sales at US companies with 201-500 employees.
Step 1: Size the audience before you spend records
Counting is free, so count first. Send count=true as a query parameter with limit=1. The JSON body rejects unknown fields.
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 '{
"jobLevel": ["VP"],
"jobFunction": ["Sales & Business Development"],
"country": ["US"],
"employeeRange": ["201-500"]
}' | jq '.meta | {total, totalIsExact, distinctCompanies}'Enum values are exact strings. VP works, vp does not for jobLevel. List the valid ones with GET /v1/enums/job_level first. The meta.total figure is capped at 100,000.
Step 2: Pull a first page of people
Request 25 people. Each person returned counts 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 '{
"jobLevel": ["VP"],
"jobFunction": ["Sales & Business Development"],
"country": ["US"],
"employeeRange": ["201-500"],
"limit": 25
}' > page1.json
jq '.meta | {count, nextCursor}' page1.json
jq -r '.data[].meta.source_ids.person_id' page1.json > person_ids.txtThe body is flat JSON, filters are arrays, and limit runs 1 to 100. To get the next page, send cursor back exactly as meta.nextCursor returned it. A search pages to 10,000 rows at most. Past that, narrow the filters.
Step 3: Enrich the people you will actually write to
Search gives you the profile. Enrichment adds career history. Pick the 10 best fits and enrich each by person_id, which every search row carries at meta.source_ids.person_id. Set reveal_email only when you will send.
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": "PERSON_ID_FROM_SEARCH", "reveal_email": true}' > person.jsonEnrich takes exactly one of linkedin_url, email, phone or person_id. There is no name plus domain lookup, so search by domain first and enrich by person_id. An enrichment that finds nothing is a 404 and costs nothing.
Since 2026-10-17 the response includes resume_data: past roles, education, certifications and awards, each when held. Past employers carry no company_id yet, so you get a name, not a link to a company record.
Step 4: Turn fields into one opener
Four fields make a usable line. Use them in this order, and stop at the first one that is present.
- A past employer in
resume_data.experiencesthat your customers also know. - A school or certification in
resume_data. - The current role start date in
contact_current_experiences(a new VP is a good reason to write). - The company's industry.
import json
p = json.load(open("person.json"))["data"]
resume = p.get("resume_data") or {}
current = (p.get("contact_data", {}).get("contact_current_experiences") or [{}])[0]
def opener(first, resume, current):
exps = resume.get("experiences") or []
past = next((e for e in exps if e.get("company_name")), None)
if past:
return f"{first}, I saw you came to {current.get('company_name', 'your team')} from {past['company_name']}."
edu = (resume.get("educations") or [None])[0]
if edu and edu.get("school_name"):
return f"{first}, I saw you studied at {edu['school_name']}."
return f"{first}, a quick question about how your team sources pipeline."
print(opener("Jane", resume, current))The API reference does not list the inner keys of resume_data. Confirm the names above (experiences, educations, company_name, school_name) against your own response before you rely on them. Print one person.json and adjust. The fallback line is deliberately plain. A generic honest line beats a wrong specific one.
Step 5: Check deliverability, then send
Read contact_data.email_status before you send. LeadOcean does not refund bounced emails. Send to verified first, treat catch_all as a second tier, and skip risky, unknown and invalid.
jq '.data.contact_data | {email_status, email_type, has_phone}' person.jsonLoad the opener and the address into the sending tool you already use. LeadOcean supplies the data only. For a CSV of the whole list, use the Exports page in the app, or POST /v1/exports (bulk CSV export is not on Free).
What you get
The audience count from the leadocean_count_leads MCP tool, run 2026-10-21 (mailable default: verified, catch_all_valid or catch_all):
{
"total": 12281,
"totalIsExact": true,
"totalAll": 35380,
"distinctCompanies": 6321,
"credits": 0
}That is 12,281 mailable VPs of sales in the US at companies with 201-500 employees, across 6,321 employers. Without the email condition the same filters match 35,380 people. The gap is the reason to read email_status.
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" }],
"contact_current_experiences": [{ "company_name": "Acme" }]
},
"resume_data": { "experiences": [], "educations": [] },
"meta": { "source_ids": { "person_id": "1234567890" } }
}
}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) | Wait for the Retry-After header, then retry |
| 402 | Out of records | The Free plan is 1,000 records for life. Move to Solo ($97 a month) or Agency ($497 a month) |
| Empty results | A bad enum value, such as a lowercase jobLevel | Look up exact strings with GET /v1/enums/job_level |
| 404 on enrich | Nobody matched the input | Costs nothing. Try another input or a different person_id |
No nextCursor with meta.depthCapped true | The search reached 10,000 rows | Narrow the filters and run smaller searches |
FAQ
How many fields do I need per prospect?
One that is true. Four give you coverage across most of a list, and the fallback handles the rest.
Does enrichment cost records twice?
Search spends one record per person returned. Enriching a person you already pulled is a separate call, so budget for both, as in the 35-record estimate above.
Can I do this from an AI assistant?
Yes. The hosted MCP server at https://api.leadocean.io/mcp has 13 tools, and leadocean_count_leads and leadocean_list_enum_values are free. It signs in with OAuth, so no key is pasted.
Where do I go for template wording?
Count your own audience first. It is free.
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →Free to start. No credit card. 1,000 records to spend whenever you like.