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

How-to guide

How to Go From B2B Lead Enrichment to Data Collection (API Pipeline)

Collect the identifiers you already have, enrich them in one call, keep only mailable rows, and refresh on a schedule.

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

Enrichment fills gaps in leads you already hold. Data collection is the pipeline around it: intake, enrich, validate, store, refresh. Build that pipeline once and every new lead lands in your table complete.

This guide is the short version. It skips the theory (see what is data enrichment) and gives you queries you can paste.

TL;DR

  • It takes about 45 minutes to run a 25-lead test end to end.
  • The test spends at most 25 LeadOcean records. A lead we hold nothing on is a 404 and costs nothing.
  • The pipeline has four stages: collect identifiers, enrich, keep mailable rows, refresh monthly.

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.
  • A file of leads with at least one identifier each: a work email, a LinkedIn URL or a phone number.
bash
export LEADOCEAN_API_KEY="paste-your-key-here"

Step 1: Collect identifiers, not guesses

Enrichment needs one anchor per lead. Enrich takes exactly one of linkedin_url, email, phone or person_id. There is no name plus company lookup.

So the collection stage has one job: capture an anchor at intake. A form with a work email field is enough. A LinkedIn URL is better because it does not bounce.

Write intake rows to a CSV with one anchor column.

csv
anchor_type,anchor
email,jane.doe@acme.com
linkedin_url,https://www.linkedin.com/in/jane-doe-example
email,sam.roe@example.com

If you only have a name and a domain, search first. GET /v1/companies/{domain}/people lists people at a domain, and each row carries a person_id you can enrich by. It pages to 10,000 rows.

Step 2: Size the audience before you spend

If you are collecting by filter instead of by list, count first. A count is free. Send count=true and limit=1 as query parameters.

bash
curl -s -G https://api.leadocean.io/v1/people/search \
  -H "x-api-key: $LEADOCEAN_API_KEY" \
  --data-urlencode "jobLevel=VP" \
  --data-urlencode "jobFunction=Sales & Business Development" \
  --data-urlencode "country=US" \
  --data-urlencode "emailStatus=verified,catch_all_valid,catch_all" \
  --data-urlencode "count=true" \
  --data-urlencode "limit=1" | jq '.meta | {total, totalIsExact, countOnly}'

meta.total stops at 100,000, so a large audience reads as a floor. Enum values are exact strings, and "Sales & Business Development" must match as written. Narrow the filters until the total fits your budget.

Step 3: Enrich each lead

One call per lead. Send reveal_email as true to get the address back. The call goes to POST /v1/people/enrich.

python
import csv, json, os, time, requests

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

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

out, missed = [], []
for row in csv.DictReader(open("intake.csv")):
    r = enrich(row["anchor_type"], row["anchor"])
    if r.status_code == 404:
        missed.append(row["anchor"])
        continue
    r.raise_for_status()
    out.append(r.json()["data"])

json.dump(out, open("enriched.json", "w"), indent=2)
print(len(out), "enriched,", len(missed), "not found")

The script retries on 429 and reads Retry-After. On the free plan the limit is 5 requests a second, so a plain loop with a short sleep stays under it.

A 404 means we hold nothing for that lead. It costs nothing, so log it and move on.

Step 4: Keep only rows you can mail

An address is not a verdict. Read email_status before a lead goes to your table. LeadOcean does not refund bounces.

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

Catch-all domains 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 in small batches.

Career history is in the same response. Since October 2026, person enrich returns resume_data with past roles, education, certifications and awards. Past employers carry no company_id yet, so match on the company name text.

Step 5: Store it with a timestamp

Keep the raw response and a collection date. The dataset refreshes monthly, and each record carries its own fetched_at date. Your table needs its own date too.

python
import json, datetime

rows = json.load(open("mailable.json"))
today = datetime.date.today().isoformat()
for d in rows:
    d["collected_at"] = today
json.dump(rows, open("lead_table.json", "w"), indent=2)

Store the person ID with each row. It lets you re-enrich later without a lookup.

Step 6: Deduplicate on the person ID

Two intake rows often point at one person: a work email and a LinkedIn URL, say. Enriching both spends two records for one lead. Dedupe before you enrich, and again after.

python
import json

seen, unique = set(), []
for d in json.load(open("lead_table.json")):
    pid = d.get("meta", {}).get("source_ids", {}).get("person_id")
    key = pid or d["contact_data"]["contact_emails"][0]["email"]
    if key in seen:
        continue
    seen.add(key)
    unique.append(d)
json.dump(unique, open("lead_table.json", "w"), indent=2)
print(len(unique), "unique leads")

The enrich docs publish person_id at meta.source_ids.person_id on an enriched record, the same place as on a search row. The key falls back to the email when it is missing. Lowercase emails before you compare them.

Step 7: Scale with exports and refresh

Cost is one record per lead found. Twenty-five leads cost at most 25 records. A 1,000-lead intake costs at most 1,000, which is the whole free allowance.

On a paid plan there are no credits. 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.

For thousands of rows, stop looping. Use POST /v1/exports, which takes up to 50,000 rows a request and 500,000 a day. Bulk CSV export is not on the free plan.

bash
curl -s -X POST https://api.leadocean.io/v1/exports \
  -H "x-api-key: $LEADOCEAN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "US sales VPs, mailable",
    "filters": {
      "jobLevel": ["VP"],
      "jobFunction": ["Sales & Business Development"],
      "country": ["US"],
      "emailStatus": ["verified", "catch_all_valid", "catch_all"]
    },
    "limit": 5000
  }'

Poll GET /v1/exports/{id}, then download the file from GET /v1/exports/{id}/download. Every row costs one record.

Prefer a UI? The Exports page in app.leadocean.io has a filter builder, a column picker, a price preview before you start and a CSV download.

Then refresh. Re-run Step 3 on rows older than 90 days, and Step 2 before each new wave. People change jobs, and a stale title is the fastest way to a bad email.

LeadOcean has no sequencer, dialer or CRM. It supplies the data. Load lead_table.json into whichever tool you send from. For Clay, see LeadOcean + Clay.

What you get

Sizing counts are free. This is the live result of leadocean_count_leads on 2026-10-21 for jobLevel VP, jobFunction Sales & Business Development, country US, counting addresses with emailStatus verified, catch_all_valid or catch_all.

json
{
  "total": 129494,
  "totalIsExact": true,
  "totalAll": 350517,
  "distinctCompanies": 58121,
  "credits": 0
}

totalAll is the same filters with no email filter. The 129,494 figure is mailable by status only. It is not a count of people who will reply.

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

json
{
  "success": true,
  "data": {
    "profile_data": { "profile_full_name": "Jane Doe", "profile_headline": "VP Sales at Acme" },
    "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", "company_domain": "acme.com", "job_title": "VP Sales" }
      ]
    },
    "resume_data": { "experiences": [], "educations": [], "certifications": [] },
    "meta": { "source_ids": { "person_id": "1234567890" } }
  },
  "meta": { "credits": 1, "schema": "leadocean.person.v1" }
}

resume_data arrays are filled when we hold past roles, education or certifications. Read one live payload before you hard-code key names inside 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
402The 1,000 free records are spentCheck GET /v1/account. Move to Solo at $97 a month or Agency at $497 a month
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 anchorSkip the row. It costs nothing
Export refusedOver 50,000 rows a request or 500,000 a daySplit the filters into smaller exports

FAQ

What is the difference between lead enrichment and data collection?

Collection is getting the lead and its anchor into your system. Enrichment adds fields to it. Treat them as two stages of one pipeline.

Can I enrich from a name and a company?

Not directly. Search by domain first with GET /v1/companies/{domain}/people, then enrich by person_id.

How many records does the pipeline cost?

One per lead we find. Counts, 404s, empty pages and enrichments that find nothing are free.

How often should I refresh?

Monthly is the ceiling, because the dataset refreshes monthly. Re-enrich rows older than 90 days before you send to them.

Are the emails safe to send?

Read email_status first. Verified and catch-all are the mailable set. Bounces are not refunded. To compare sources, see best lead database and best lead enrichment tools. Plans are on the pricing page.

Start collecting clean B2B lead data free

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

Get your free API key →