Pre-Black Friday: 20% off, for as long as you stayEARLYBFon Solo and AgencyEnds Nov 28Start free

How-to guide

How to Personalize B2B Sales Communication (With the LeadOcean API)

Four fields per prospect, one opener per field, and a fallback for when the field is empty.

Get your free API key →Free to start. No credit card. 1,000 records to spend whenever you like.

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.
  • curl and jq, 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.

bash
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.

bash
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.txt

The 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.

bash
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.json

Enrich 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.

  1. A past employer in resume_data.experiences that your customers also know.
  2. A school or certification in resume_data.
  3. The current role start date in contact_current_experiences (a new VP is a good reason to write).
  4. The company's industry.
python
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.

bash
jq '.data.contact_data | {email_status, email_type, has_phone}' person.json

Load 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):

json
{
  "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:

json
{
  "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

ErrorCauseFix
401Missing or wrong x-api-keyCheck $LEADOCEAN_API_KEY is exported in this shell
429Above the plan's rate limit (5 a second on Free, 200 on Agency)Wait for the Retry-After header, then retry
402Out of recordsThe Free plan is 1,000 records for life. Move to Solo ($97 a month) or Agency ($497 a month)
Empty resultsA bad enum value, such as a lowercase jobLevelLook up exact strings with GET /v1/enums/job_level
404 on enrichNobody matched the inputCosts nothing. Try another input or a different person_id
No nextCursor with meta.depthCapped trueThe search reached 10,000 rowsNarrow 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.

Get your free API key