Hyper-personalization means one verifiable fact per prospect that only applies to them, placed in the first line. You get that fact from structured data, not from a template with a first-name merge. This guide builds the pipeline: size, search, enrich, pick a fact, write.
TL;DR
- Pipeline: free count, people search, enrich with career history, pick one fact, generate one opening line. About 30 minutes.
- Cost: 20 LeadOcean records for a 10-person test batch (10 search rows, 10 enrichments). The free plan has 1,000 records for life.
- Rule: the model may only use fields you pass it. If a field is empty, the email falls back to a plain line. No invented facts.
Prerequisites
- A LeadOcean API key in
$LEADOCEAN_API_KEY(free plan, no card: pricing). curlandjq.- Any LLM you can call with a prompt. Claude Code or Claude with the LeadOcean MCP server works; see the Claude recipe.
- A sending tool. LeadOcean is a data API with an Exports page in the app. It has no sequencer.
Step 1: Size the list for free
Count before you spend. A count costs no records. Filters are exact strings, so check enum values first.
curl -s "https://api.leadocean.io/v1/enums" -H "x-api-key: $LEADOCEAN_API_KEY" | jq 'keys'The free MCP tool leadocean_count_leads takes the same filters. On 21 October 2026 it returned this for VPs in sales and business development in Canada:
{"total": 5574, "totalIsExact": true, "totalAll": 18800, "distinctCompanies": 3873}total counts mailable people only (verified, catch_all_valid or catch_all). totalAll has no email condition. Pick a segment you can actually write to: a few thousand is plenty for one angle.
Step 2: Pull a 10-row test batch
Search with a flat JSON body. Filters are arrays of strings, limit runs 1 to 100, and you page with cursor.
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":["CA"],"limit":10}' \
> batch.json
jq -r '.data[].meta.source_ids.person_id' batch.json > ids.txtThis spends 10 records. Always test on 10 before you run 1,000. Search pages to 10,000 rows per query; narrow the filters instead of walking further.
Step 3: Enrich each person for career history
Enrich takes exactly one of linkedin_url, email, phone or person_id. Since October 2026 it returns career history too: past roles, education, certifications and awards, each when held.
mkdir -p enriched
while read id; do
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\":\"$id\"}" > "enriched/$id.json"
sleep 0.3
done < ids.txtFree plan limit is 5 requests a second, so sleep 0.3 keeps you under it. A person with no match returns 404 and costs nothing. Past employers carry no company_id yet, so do not join them to company records.
Step 4: Pick one fact per prospect
Rank the facts by how hard they are to fake. A past employer plus a start date beats a job title. A certification beats a headline.
jq -c '.data | {
name: .profile_data.profile_full_name,
role: .contact_data.contact_current_experiences[0].job_title,
company: .contact_data.contact_current_experiences[0].company_name,
prior: ((.resume_data.experiences // [])[0]
| if . then {job_title, company_name, end_date} else null end),
cert: (.resume_data.certifications // [])[0]
}' enriched/*.json > facts.jsonlThe current role sits in contact_data.contact_current_experiences[]. Past roles sit in resume_data.experiences[] with job_title, company_name, start_date and end_date, as the MCP docs list them. The current role is not repeated there, so [0] is the most recent past role when the profile lists them newest first. Keep one fact per person and drop the rest.
Step 5: Score the facts before you write
Not every fact earns a line. Score each one before it reaches the model, and drop anything under 2.
| Fact | Score | Why |
|---|---|---|
| Past employer plus move date | 3 | Specific, checkable, and about them |
| Certification or award | 3 | Rare, and they chose to list it |
| School | 2 | Works only when you share a reason to mention it |
| Current job title | 1 | Every recipient with that title gets the same line |
| Company headcount band | 1 | Describes the company, not the person |
jq -c 'select(.prior != null or .cert != null)' facts.jsonl > scored.jsonl
wc -l scored.jsonlThe line count tells you the real personalization rate of your segment. If only 4 of 10 rows survive, send those 4 the personal line and the other 6 a plain, well-targeted email. That is honest, and it beats forcing a weak fact into every message.
Step 6: Write the line with a locked prompt
Give the model the fact and forbid everything else. Ask for one opening line, not a whole email.
You write one opening line for a cold email.
Use ONLY the JSON below. Do not add facts, numbers or praise.
If "prior" is null and "cert" is null, output: SKIP
Max 22 words. No flattery. Reference the fact as a plain observation.
{"name":"Jane Doe","role":"VP Sales","company":"Acme","prior":{"job_title":"Director of Sales","company_name":"Globex","end_date":"2024-03-31"}}A good output reads: "Jane, you moved from Globex to lead sales at Acme. Teams rebuilding outbound after a move usually start with the list." The SKIP rule matters. A blank fact set gets a plain email, not a made-up one.
Then add the rest of the email by hand: one problem, one offer, one question. See how to write personalized cold email outreach for the body. For website-based facts, use the website data recipe.
Step 7: Check the line before you send
Review 10 generated lines by eye before you run 1,000. Reject a line when it breaks any of these:
- It states something that is not in the JSON you passed.
- It compliments instead of observing ("impressive career").
- It runs over 22 words.
- It would still make sense if you swapped the prospect's name for anyone else.
The last test is the one that matters. If a line survives a name swap, it is not personalized.
Step 8: Get the email addresses and send
Request the work email only for people you will actually email. Check email_status and send to verified or catch_all_valid only. There is no refund for bounces.
curl -s "https://api.leadocean.io/v2/people/email/work?person_id=PERSON_ID" \
-H "x-api-key: $LEADOCEAN_API_KEY"For a larger run, use POST /v1/exports (up to 50,000 rows a request, 500,000 a day; bulk CSV is not on Free) or the Exports page at app.leadocean.io. Load the CSV and the generated lines into your sequencer.
What you get
Response shape from the API reference, placeholder values only:
{
"success": true,
"data": {
"profile_data": {"profile_full_name": "Jane Doe"},
"contact_data": {
"email_status": "verified",
"contact_current_experiences": [{"job_title": "VP Sales", "company_name": "Acme", "company_domain": "acme.com"}]
},
"resume_data": {
"experiences": [{"job_title": "Director of Sales", "company_name": "Globex", "start_date": "2019-01-01", "end_date": "2024-03-31"}],
"certifications": []
},
"meta": {"source_ids": {"person_id": "PERSON_ID"}}
}
}Output of the whole run for one prospect: a facts.jsonl row, one generated line, and one verified work email. Spend for the 10-row batch: 10 search records plus 10 enrichments, 20 records.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing or wrong x-api-key header | Check $LEADOCEAN_API_KEY is exported in your shell |
| 429 | Above the plan's rate limit (5 a second on Free, 200 on Agency) | Honour Retry-After; add a sleep between calls |
| 402 | Out of records (Free is 1,000 for life) | Check GET /v1/account; move to Solo at $97 a month |
| Empty results | Enum filter value spelled wrong, for example vp instead of VP for jobLevel | Look up exact strings with GET /v1/enums or leadocean_list_enum_values |
| 404 on enrich | Nothing held for that person | No charge; skip and move on |
| Export refused | Over 50,000 rows a request or 500,000 a day | Split the export into smaller requests |
FAQ
How many facts should one email use?
One. Two facts read as research; one reads as a human noticing. Put it in the first sentence and move to the problem.
Is career history available for everyone?
No. Each field returns only when held. That is why the prompt has a SKIP branch.
Can the model find facts on its own?
It can, and it will invent some. Pass only fields you have, and reject lines that mention anything outside the JSON.
What does a full run cost?
A 10-person test is 20 records, free on the Free plan. Paid plans run on a fair-use pace, not credits. See pricing.
Personalize from real fields, not guesses
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →