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

How-to guide

How to Enrich a CSV with B2B Person, Company, and IP Data (API Script)

Two API calls, one short script, one new CSV. Person fields from a LinkedIn URL or email, company fields from a domain, and an honest note on IP columns.

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

You enrich a CSV by sending each row's identifier to an API and writing the answer into new columns. This guide does it with two LeadOcean calls and a short Python script. It also covers the part most guides skip: what to do with an IP column.

People Data Labs sells a point-and-click version of this called Batch Enrich (PDL blog, September 2026). This is the scripted version. You keep the code, the mapping and the output.

TL;DR

  • Person enrichment takes a LinkedIn URL, email, phone or person_id. Company enrichment takes a domain. Each call returns one JSON record.
  • A first test of 100 rows takes about 20 minutes and spends at most 100 records. A row that finds nothing is a 404 and costs nothing.
  • LeadOcean has no IP-to-company lookup. Resolve the IP to a domain first, then enrich the domain.

Prerequisites

  • A LeadOcean API key from app.leadocean.io. Free gives 1,000 records, one-off, no card, 5 requests a second.
  • The key in your shell: export LEADOCEAN_API_KEY=...
  • Python 3 and pip install requests.
  • A CSV with at least one of: a LinkedIn profile URL, a work email, or a company domain. A name alone does not work.

Step 1: Check the file and the budget

Count your usable rows before you spend anything. Pick the strongest identifier per row: LinkedIn URL, then email. Note that enrich takes exactly one identifier. Sending two is a 400.

bash
python3 - <<'PY'
import csv
rows = list(csv.DictReader(open("leads.csv")))
usable = [r for r in rows if r.get("linkedin_url") or r.get("email")]
print(len(rows), "rows,", len(usable), "usable")
PY

Your input looks like this (placeholder data):

csv
linkedin_url,email,company_domain
https://www.linkedin.com/in/jane-doe,jane@acme.com,acme.com

The worst case is one record per usable row. Real spend is lower because misses are free.

Step 2: Test one person by hand

Run one request before you loop. This confirms the key and shows the response.

bash
curl -X POST "https://api.leadocean.io/v1/people/enrich" \
  -H "x-api-key: $LEADOCEAN_API_KEY" \
  -H "content-type: application/json" \
  -d '{"linkedin_url":"https://www.linkedin.com/in/jane-doe","reveal_email":true}'

reveal_email: true returns every email and phone we hold at no extra record. A hit is 1 record. A 404 means we hold nothing and costs nothing.

Step 3: Enrich the people

This script reads leads.csv, calls person enrich once per row and writes people.csv. It sleeps 0.25 seconds between calls to stay under the Free limit of 5 requests a second. On Agency (200 a second) you can drop the sleep.

python
import csv, json, os, time, requests

API = "https://api.leadocean.io/v1/people/enrich"
H = {"x-api-key": os.environ["LEADOCEAN_API_KEY"], "content-type": "application/json"}

def enrich(row):
    key = "linkedin_url" if row.get("linkedin_url") else "email"
    body = {key: row[key], "reveal_email": True}
    while True:
        r = requests.post(API, headers=H, json=body, timeout=30)
        if r.status_code == 429:
            time.sleep(float(r.headers.get("Retry-After", 1)))
            continue
        return r

rows = list(csv.DictReader(open("leads.csv")))
out, hits = [], 0
for row in rows:
    if not (row.get("linkedin_url") or row.get("email")):
        out.append({**row, "lo_status": "skipped"}); continue
    r = enrich(row)
    if r.status_code == 404:
        out.append({**row, "lo_status": "not_found"})
    elif r.status_code != 200:
        raise SystemExit(f"{r.status_code}: {r.text}")
    else:
        d = r.json()["data"]
        c, p = d.get("contact_data", {}), d.get("profile_data", {})
        hits += 1
        out.append({**row, "lo_status": "found",
            "lo_name": p.get("profile_full_name"),
            "lo_headline": p.get("profile_headline"),
            "lo_email_status": c.get("email_status"),
            "lo_email_type": c.get("email_type"),
            "lo_has_phone": c.get("has_phone"),
            "lo_current_role": json.dumps(c.get("contact_current_experiences"))})
    time.sleep(0.25)

keys = list({k: 1 for r in out for k in r})
w = csv.DictWriter(open("people.csv", "w", newline=""), keys)
w.writeheader(); w.writerows(out)
print(f"{hits} found of {len(rows)} rows = {hits} records spent")

The last line is your spend. Your own columns pass through untouched. New values go in lo_ columns.

Step 4: Enrich the companies

Company enrich takes a domain. Take it from the company_domain column, or from the email address when the column is empty. Skip free-mail domains. Enrich each unique domain once, however many rows share it.

python
import csv, json, os, time, requests

API = "https://api.leadocean.io/v1/companies/enrich"
H = {"x-api-key": os.environ["LEADOCEAN_API_KEY"], "content-type": "application/json"}
FREE = {"gmail.com", "yahoo.com", "outlook.com", "hotmail.com"}

rows = list(csv.DictReader(open("people.csv")))
def dom(r):
    d = r.get("company_domain") or (r.get("email", "").split("@")[-1] if r.get("email") else "")
    return d.lower() if d and d.lower() not in FREE else ""

cache = {}
for d in {dom(r) for r in rows} - {""}:
    r = requests.post(API, headers=H, json={"domain": d}, timeout=30)
    if r.status_code == 200:
        cd = r.json()["data"]["company_data"]
        cache[d] = {"lo_company": cd.get("company_name"),
                    "lo_industries": json.dumps(cd.get("company_industries")),
                    "lo_employees": json.dumps(cd.get("company_employees")),
                    "lo_hq": json.dumps(cd.get("company_primary_address"))}
    time.sleep(0.25)

out = [{**r, **cache.get(dom(r), {})} for r in rows]
keys = list({k: 1 for r in out for k in r})
w = csv.DictWriter(open("enriched.csv", "w", newline=""), keys)
w.writeheader(); w.writerows(out)
print(len(cache), "companies found =", len(cache), "records spent")

Company records also carry company_detected_technologies and company_metrics beside company_data. Add them to the cache the same way if you need them.

Step 5: Handle IP addresses

LeadOcean does not map an IP address to a company. There is no IP endpoint in the API reference. Do not hope a lookup is hiding in the people endpoints.

The route that works is two hops. Resolve each IP to a company domain with a reverse-IP or visitor-identification tool you already use. Write the domain into the company_domain column. Then run Step 4.

csv
ip,company_domain
203.0.113.7,acme.com

203.0.113.7 is a documentation address. Treat the domain a reverse-IP tool returns as a lead, not a fact. Shared offices, VPNs and cloud ranges point at the wrong company often.

Step 6: Check match rate and fill rate

Match rate is found rows divided by usable rows. Fill rate is how often one field is filled among the found rows. Check both before you load the file anywhere.

python
import csv
rows = list(csv.DictReader(open("enriched.csv")))
found = [r for r in rows if r.get("lo_status") == "found"]
print("match rate:", round(100 * len(found) / len(rows)), "%")
for col in ("lo_email_status", "lo_company"):
    filled = sum(1 for r in found if r.get(col))
    print(col, "fill rate:", round(100 * filled / max(len(found), 1)), "%")

Run this on the 100-row test first. If match rate is low on a LinkedIn-URL column, check that the URLs are profile URLs and not company pages. Then decide if the full file is worth running. Roughly one person in five has no LinkedIn URL in our data, so email or person_id matters for those rows.

Step 7: Or skip the script and export from the app

If you do not have identifiers and want a list that matches filters instead, use the Exports page in the app. It has a filter builder, a column picker, a price preview and a CSV download. Bulk CSV export is not on Free. Solo includes it.

The API does the same with POST /v1/exports. One request takes up to 50,000 rows and the account is capped at 500,000 rows a day. Size the list for free first:

bash
curl "https://api.leadocean.io/v1/companies/count?hqCountry=US&employeeRange=51-200" \
  -H "x-api-key: $LEADOCEAN_API_KEY"

What you get

Free sizing calls cost no records. This is a real result from leadocean_count_leads, run on 2026-10-21 for title: Head of Sales, country: US:

json
{"total":6564,"totalIsExact":true,"totalAll":16363,"distinctCompanies":4932,"credits":0}

The 6,564 counts people with a verified or catch-all address (the default). Without an email filter the same query matches 16,363. That is a mailable count, not everyone.

A person record has this shape (response shape from the API reference, placeholder values):

json
{
  "success": true,
  "data": {
    "profile_data": { "profile_full_name": "Jane Doe", "profile_headline": "Head of Sales at Acme" },
    "contact_data": {
      "has_email": true,
      "has_phone": true,
      "email_status": "verified",
      "email_type": "work"
    },
    "resume_data": {}
  },
  "meta": { "schema": "leadocean.person.v1", "credits": 1 }
}

resume_data holds past roles, education and certifications where we hold them. Past employers have no company_id yet, so you cannot join them to a company record.

Your output file ends up like this:

csv
linkedin_url,email,company_domain,lo_status,lo_name,lo_email_status,lo_company
https://www.linkedin.com/in/jane-doe,jane@acme.com,acme.com,found,Jane Doe,verified,Acme

Read lo_email_status before you send. verified and catch_all_valid are the ones to send to. Bounces are not refunded.

Troubleshooting

ErrorCauseFix
401Missing or wrong x-api-key headerCheck $LEADOCEAN_API_KEY is exported in this shell.
400 on enrichMore than one identifier in the body, or noneSend exactly one of linkedin_url, email, phone, person_id.
404We hold nothing for that identifierNot an error. It costs nothing. Mark the row not_found.
429Above the plan's rate limit: 5 a second on Free, 200 on AgencySleep for the Retry-After header, as in Step 3, or lower your request rate.
402Out of records. Free is 1,000, one-offCheck GET /v1/account, then move to a paid plan.
Empty search or countA filter value that is not an exact enum stringCheck GET /v1/enums or leadocean_list_enum_values. jobLevel is VP, not vp.
Export 400 or 429Over 50,000 rows a request, or 500,000 a daySplit the export or narrow the filters.

FAQ

Can I enrich a CSV with only names and companies?

No. Enrich needs a LinkedIn URL, email, phone or person_id. There is no name-plus-company lookup. Search by domain first, then enrich by person_id from the row.

What does enriching 10,000 rows cost?

At most 10,000 records, fewer if some rows are not found. Free covers the first 1,000 once. Solo is $97 a month with 10k enrichments every 5 hours. Agency is $497 a month. See pricing.

Can I enrich IP addresses?

Not directly. Resolve IPs to domains with a separate tool, then run company enrich on the domain. See Step 5.

Can Claude do this for me instead of a script?

Yes. The Enrich Person skill runs the same lookups through the MCP server. For field details, see person enrichment and company enrichment.

Enrich your first 1,000 rows free

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

Get your free API key →