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,jqandpython3withrequests.- A file of leads with at least one identifier each: a work email, a LinkedIn URL or a phone number.
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.
anchor_type,anchor
email,jane.doe@acme.com
linkedin_url,https://www.linkedin.com/in/jane-doe-example
email,sam.roe@example.comIf 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.
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.
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.
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.jsonCatch-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.
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.
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.
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.
{
"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.
{
"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
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing or wrong x-api-key header | Re-export $LEADOCEAN_API_KEY and check for stray quotes |
| 429 | Above the plan's rate limit (5 a second on Free, 200 on Agency) | Read Retry-After, sleep, retry |
| 402 | The 1,000 free records are spent | Check GET /v1/account. Move to Solo at $97 a month or Agency at $497 a month |
Empty data on search | A filter value that is not an exact enum string | Call GET /v1/enums/job_function and copy the value as written |
| 404 on enrich | We hold nothing for that anchor | Skip the row. It costs nothing |
| Export refused | Over 50,000 rows a request or 500,000 a day | Split 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 →