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

How-to guide

How to Personalize at Scale With Enrichment Data (API and CSV)

Eleven enrichment tools marketing teams reach for, reduced to the fields that matter, with the real queries that pull them.

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

You personalize at scale by choosing a few enrichment fields that change what you write, then pulling only those. Most lists of "11 enrichment tools that helped our marketing team personalize at scale" give you logos. This one gives you the fields and the queries.

Eleven tools is the wrong unit. Personalization comes from about eleven data points. One API can return most of them.

TL;DR

  • It takes about 40 minutes: 10 to size a segment, 15 to enrich 10 people, 15 to build merge fields.
  • The 10-person test spends up to 20 LeadOcean records. Search is 1 record per person and enrich is 1 more.
  • Sizing the segment is free. Use only fields you will print in the message.

Prerequisites

  • A LeadOcean API key from app.leadocean.io. The free plan has 1,000 records for life and no card.
  • The key in your shell as $LEADOCEAN_API_KEY.
  • curl, jq and python3 with requests.
  • Optional: a Clay table or a sequencer that accepts a CSV.
bash
export LEADOCEAN_API_KEY="paste-your-key-here"

Step 1: Pick the eleven fields before the tools

Each field must change a sentence. If it does not, do not pay for it. Here is the set that marketing teams use most, and where LeadOcean returns it.

#FieldWhat it changes in the copyWhere it comes from
1Job titleThe pain you nameSearch row, contact_current_experiences[]
2Job levelTone and ask sizejobLevel filter
3Job functionWhich proof you citejobFunction filter
4Company nameOpening lineSearch row
5IndustryExample you useindustry filter
6Headcount bandTeam size assumptionemployeeRange filter
7CountryLanguage and timingcountry filter
8Past rolesA shared-history hookresume_data.experiences[]
9EducationAn alumni hookresume_data.educations[]
10Email statusWhether to send at allcontact_data.email_status
11Phone presentWhether a call step existscontact_data.has_phone

Most roundups sell you a tool per row of that table. That is how a team ends up with eleven logins and one half-filled sheet. Group the fields by the question they answer instead.

  • Who is this? Title, level, function and company. These come from search and cost nothing extra once you pay for the row.
  • Is this the right company? Industry, headcount band and country. These are filters, so you set them once and never re-check them per row.
  • What do we share? Past roles and education. These are the only fields that read as personal, and they need enrich.
  • Can we reach them? Email status and phone flag. These decide the channel, not the copy.

A personalization field earns its place only if you print it, branch on it or filter on it. Anything else is data you paid for and ignored.

Fields 8 and 9 arrived in October 2026. Person enrich now returns career history, and past employers carry no company_id yet. Match on the company name text.

Step 2: Size the segment for free

Count the audience before you spend a record. Send count=true as a query parameter and limit 1. A sizing call costs nothing however often you send it.

bash
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": ["Advertising & Marketing"],
    "country": ["US"],
    "employeeRange": ["51-200"],
    "emailStatus": ["verified", "catch_all_valid", "catch_all"],
    "limit": 1
  }' | jq '.meta | {total, countOnly}'

count=true goes in the query string, not the JSON body. meta.total stops at 100,000. Enum values are exact strings, and marketing is "Advertising & Marketing", not "Marketing".

Step 3: Search and keep the person IDs

Search returns one row per person at 1 record each. The body is flat JSON and filters are arrays. Each row carries meta.source_ids.person_id, which is the handle you enrich by.

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": ["Advertising & Marketing"],
    "country": ["US"],
    "employeeRange": ["51-200"],
    "emailStatus": ["verified", "catch_all_valid", "catch_all"],
    "limit": 10
  }' | jq -c '.data[] | .meta.source_ids.person_id' > person_ids.txt
wc -l person_ids.txt

Step 4: Enrich each person and build merge fields

Enrich takes exactly one of linkedin_url, email, phone or person_id. Send reveal_email as true to get the addresses back. A person we hold nothing for is a 404 and costs nothing.

python
import json, os, time, requests

BASE = "https://api.leadocean.io"
H = {"x-api-key": os.environ["LEADOCEAN_API_KEY"], "Content-Type": "application/json"}

def enrich(pid):
    for _ in range(5):
        r = requests.post(BASE + "/v1/people/enrich", headers=H, timeout=60,
                          json={"person_id": pid, "reveal_email": True})
        if r.status_code != 429:
            return r
        time.sleep(int(r.headers.get("Retry-After", "1")))
    return r

rows = []
for line in open("person_ids.txt"):
    r = enrich(json.loads(line))
    if r.status_code == 404:
        continue
    r.raise_for_status()
    d = r.json()["data"]
    c = d.get("contact_data", {})
    cur = (c.get("contact_current_experiences") or [{}])[0]
    past = (d.get("resume_data", {}).get("experiences") or [])[1:2]
    edu = (d.get("resume_data", {}).get("educations") or [])[:1]
    rows.append({
        "email": next((e.get("email") for e in c.get("contact_emails", [])), None),
        "email_status": c.get("email_status"),
        "has_phone": c.get("has_phone"),
        "title": cur.get("title"),
        "previous_role": past[0] if past else None,
        "education": edu[0] if edu else None,
    })

json.dump(rows, open("merge_fields.json", "w"), indent=2)
print(len(rows), "people enriched")

The script takes the second experience as the previous role. That is a guess about ordering, so print a few rows and confirm which entry is the current job before you trust it. Education is the first entry for the same reason.

Keep the output small. A merge field should be a short string you can drop into a sentence, such as a company name or a job title. Do not push whole JSON blobs into your sequencer.

Test on 10 first. Print the merge fields and read them. A field that is empty for most rows is a field you cannot build copy on.

Step 5: Check the cost before you scale

Do the arithmetic on the test, then multiply. Ten people cost up to 20 records, so 1,000 people cost about 2,000. The Free plan covers the 10-person test and a first wave of roughly 490 people.

On a paid plan there is no per-record price. Solo at $97 a month and Agency at $497 a month each have a fair-use pace. Reaching it slows the key until the window resets. It does not stop you or bill an overage.

Step 6: Keep only mailable rows

Enrichment returns an address. It does not tell you the address is safe to send. Filter on email_status first, because LeadOcean gives no refund for bounced emails.

bash
jq '[.[] | select(.email_status == "verified"
  or .email_status == "catch_all_valid"
  or .email_status == "catch_all")]' merge_fields.json > mailable.json
jq 'length' merge_fields.json mailable.json

Catch-all addresses accept any mail at the server, so some will still bounce. If your sending domain is new, start with verified only and add catch-all rows later in small batches.

Then write one conditional line per field. If previous_role is empty, send the version without it. Never print a blank merge tag.

Step 7: Move it into your tool

Whichever route you take, send in waves. Personalized copy needs a read of the first replies, and a wave of 50 tells you more than a blast of 5,000. Re-run Step 2 before each wave, because the segment changes as the dataset refreshes monthly.

You have two routes. Pick by volume.

  • Under a few hundred rows: load mailable.json into your sequencer or paste it into a Clay table. Clay's HTTP API enrichment calls any endpoint from a table (Clay docs, October 2026). You can call POST /v1/people/enrich there directly. See the Clay integration page.
  • Thousands of rows: use the Exports page in app.leadocean.io. It has a filter builder, a column picker and a price preview, then a CSV download. Bulk CSV export is not on the Free plan.

API exports take up to 50,000 rows a request and 500,000 a day via POST /v1/exports. LeadOcean has no sequencer, dialer or CRM. It supplies the data.

What you get

Enum lookups are free. This is the live response for the marketing job function, run on 2026-10-21 from GET /v1/enums/job_function. The value you filter on is "Advertising & Marketing".

json
{
  "name": "job_function",
  "total": 22,
  "values": ["Advertising & Marketing", "Art, Culture and Creative Professionals",
             "Construction", "Customer/Client Service", "Education", "Engineering",
             "Finance & Accounting", "General Business & Management", "..."]
}

We ran the Step 2 filters through the free leadocean_count_leads tool on 2026-10-21. It returned this (trimmed):

json
{
  "total": 6761,
  "totalIsExact": true,
  "totalAll": 20683,
  "distinctCompanies": 4910,
  "credits": 0
}

That is 6,761 mailable people across 4,910 companies. Without the email condition the same filters match 20,683. Run Step 2 yourself for today's number.

Below is the enrich response shape from the API reference, with placeholder values. It is not a real person.

json
{
  "success": true,
  "data": {
    "contact_data": {
      "email_status": "verified",
      "has_phone": true,
      "contact_emails": [{ "email": "jane.doe@acme.com" }],
      "contact_current_experiences": [{ "title": "VP Marketing", "company_name": "Acme" }]
    },
    "resume_data": {
      "experiences": [{ "title": "Director of Demand Gen", "company_name": "Example Co" }],
      "educations": [{ "school_name": "Example University" }]
    }
  }
}

Response shape from the API reference. Inner key names in resume_data follow the documented sections; check the live payload before you hard-code them.

Troubleshooting

ErrorCauseFix
401Missing or wrong x-api-key headerRe-export $LEADOCEAN_API_KEY and check for stray quotes
429Above the plan's rate limit (5 a second on Free, 200 on Agency)Read Retry-After, sleep, retry. Space calls out
402The 1,000 free records are spentCheck GET /v1/account. Move to Solo at $97 a month or Agency at $497
Empty data on searchA filter value that is not an exact enum stringCall GET /v1/enums/job_function and copy the value as written
404 on enrichWe hold nothing for that personSkip the row. It costs nothing
Export refusedOver 50,000 rows a request or 500,000 a daySplit the filters into smaller exports

FAQ

Do I need eleven separate enrichment tools to personalize?

No. Most of the eleven fields come back from one people search and one enrich call. Add a second source only for a field you can prove you use.

Which fields lift reply rates most?

We have no measured result to quote. Test one field at a time against a control. Past roles and education are cheap to test because they are new in enrich.

How many records does a campaign cost?

Two per person if you search then enrich. Search alone returns most filter fields at 1 record. Counts, breakdowns, 404s and empty pages cost nothing.

Can I personalize without writing code?

Yes. Build the filter in the app, pick columns, check the price preview and download the CSV. Bulk CSV export needs Solo or higher.

Are the emails safe to send?

Read email_status first. Verified and catch-all addresses are the mailable set. LeadOcean does not refund bounces.

Pull your first personalization fields free

Free to start. No credit card. 1,000 records to spend whenever you like.

Get your free API key →