TL;DR
- Most enrichment errors come from six causes: stale records, weak inputs, unread email status, job moves, coverage gaps and no feedback from bounces. Each has a check you can run in a few lines.
- It takes about 40 minutes. The counts are free. The live calls spend about 12 LeadOcean records.
- The shared pattern: size first with the free count, read the status fields in every response, and write what happened back to your list.
Crustdata's guide on lead enrichment accuracy challenges (checked September 2026) covers the topic in prose. This page is the version with requests and output.
Prerequisites
- A LeadOcean API key from app.leadocean.io, exported as
$LEADOCEAN_API_KEY. The Free plan has 1,000 records for life, no card, and both the search and enrich scopes. curlandjq. Python 3 for Step 6.- Optional: the LeadOcean MCP server at
https://api.leadocean.io/mcp, which gives Claude the same free count tool.
Step 1: Stale records. Re-enrich on a schedule
Records go stale because people change jobs and your list does not. The LeadOcean dataset refreshes monthly, and each record carries its own fetched_at date in meta.fetched_at. So the fastest useful re-check is monthly.
Search returns a person_id on each row. Keep it, and enrich by it later. One enrich call is one record. A person we hold nothing for is a 404 and costs nothing.
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":"1234567","reveal_email":true}' |
jq '{
title: .data.contact_data.contact_current_experiences[0].job_title,
domain: .data.contact_data.contact_current_experiences[0].company_domain,
status: .data.contact_data.email_status
}'Compare title and domain with what your list holds. A changed domain means the person moved. A changed title on the same domain is a promotion or a new role, so update the row and keep the lead. Re-check open deals every 30 days, active leads every 90 and the rest every 180. Re-checking faster than monthly returns the same record.
Step 2: Weak inputs. Search by domain, then enrich by id
Low match rates usually come from a bad key, not a thin database. Enrich takes exactly one of linkedin_url, email, phone or person_id. There is no name plus company lookup, so a messy "Acme Inc." field cannot be matched directly.
Normalize the company to a domain first. Then search for the person at that domain and enrich the id.
curl -s -X POST https://api.leadocean.io/v1/people/search \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "content-type: application/json" \
-d '{"domain":["acme.com"],"title":["Head of Sales"],"limit":5}' |
jq -r '.data[] | [.meta.source_ids.person_id, .profile_data.profile_full_name] | @tsv'Search spends 5 records here. Take the person_id of the right row and enrich it, as in Step 1. Store the id with the lead, because it is the only key that always round-trips to the same person. About a fifth of the people search can find have no LinkedIn URL, so an id is the safer handle than a profile link.
If a domain returns nothing, fix the domain, not the provider. Strip https://, www. and paths before you send it. For a whole company, GET /v1/companies/{domain}/people lists its people.
Step 3: Unread email status. Count by status before you pull
An address with no status is a guess. LeadOcean returns email_status with 13 possible values, and there is no refund for a bounce. Read the status before every send.
The free count shows how much a status filter changes your list. On 2026-10-21 the count tool sized one segment three ways.
curl -s -G https://api.leadocean.io/v1/people/search \
-H "x-api-key: $LEADOCEAN_API_KEY" \
--data-urlencode "jobLevel=VP" \
--data-urlencode "country=US" \
--data-urlencode "emailStatus=verified" \
--data-urlencode "count=true" \
--data-urlencode "limit=1" | jq '.meta | {total, totalIsExact, countOnly}'Send count=true as a query parameter with limit=1. It costs no records and returns an empty data. The gap between the three counts is the point. The default pull is about 40% of everyone matched, and the verified-only pull is about 8%. Choose the row that matches your tolerance for bounces, and write the choice down so the next list uses the same rule.
Then pull only the statuses you will send to: verified, catch_all_valid or catch_all. Drop risky, unknown, invalid, role, disposable, spam_trap and abuse.
Step 4: Job moves. Read the email type and career history
An email can be valid and still belong to the wrong employer. The email_type field says work_other when the best address belongs to a former employer. Treat it as the likeliest bounce.
Person enrich also returns resume_data since October 2026: past roles, education, certifications and awards. Past employers carry no company_id yet, so match them by name.
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":"1234567","reveal_email":true}' |
jq '{
email_type: .data.contact_data.email_type,
current: .data.contact_data.contact_current_experiences[0].company_name,
past_roles: [.data.resume_data.experiences[]? | {job_title, company_name}]
}'If email_type is work_other, do not send. Look for another address in contact_emails, or re-search the person at their new domain from Step 2.
Step 5: Coverage gaps. Measure the field before you buy the list
Every database has gaps, and one source means one set of gaps. The answer is to measure the field you need on your own segment before you commit. Do not assume the average.
Filter on the contact field and count. hasPhone, hasEmail and reachable are search filters. reachable=strict requires an email that is verified, catch_all_valid or catch_all.
curl -s -G https://api.leadocean.io/v1/people/search \
-H "x-api-key: $LEADOCEAN_API_KEY" \
--data-urlencode "jobLevel=VP" \
--data-urlencode "country=US" \
--data-urlencode "hasPhone=true" \
--data-urlencode "count=true" \
--data-urlencode "limit=1" | jq '.meta.total'If the number is too low for your segment, the gap is real. Plan a second source for that field, or narrow to the segment where coverage is dense. The phone count above shows how different two fields in one segment can look. Run the same check for every field your sequence depends on. Know that before you pay for a list, not after the sequence runs.
Step 6: No feedback loop. Log outcomes and feed them back
Enrichment gets better only if the result of each send reaches your list. LeadOcean does not see your bounces, and there is no credit-back for them. So keep the loop yourself.
import csv, sqlite3
db = sqlite3.connect("leads.db")
db.execute("""CREATE TABLE IF NOT EXISTS leads(
person_id TEXT PRIMARY KEY, email TEXT, email_status TEXT,
outcome TEXT, next_check TEXT)""")
for row in csv.DictReader(open("bounces.csv")):
db.execute("UPDATE leads SET outcome=?, next_check=date('now') WHERE person_id=?",
(row["outcome"], row["person_id"]))
db.commit()
due = db.execute("SELECT person_id FROM leads WHERE outcome='bounced' "
"AND next_check <= date('now')").fetchall()
print("re-enrich these first:", [r[0] for r in due])Export your sequencer's results as bounces.csv with the columns person_id and outcome. Run Step 1 on the due ids. A bounce is the strongest signal you have that a record moved. After a month, compare bounce rates by email_status. That tells you which statuses you can trust in your market.
What you get
The free count tool sized one segment on 2026-10-21: jobLevel VP, country US. The same filters, three different answers.
| What was filtered | People |
|---|---|
| No email filter | 3,102,417 |
| Tool default (verified, catch_all_valid or catch_all) | 1,242,801 |
emailStatus verified only | 257,840 |
A phone filter on the same segment (hasPhone true, no email filter) returned 2,347,861 people on the same date. These are counts of people matched, not mailable lists, except the two rows that name an email status.
The enrich call returns the record below. This is the response shape from the API reference, with placeholder values.
{
"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@acme.com", "type": "work", "status": "verified" }],
"contact_current_experiences": [
{ "company_name": "Acme", "company_domain": "acme.com", "job_title": "Head of Sales" }
]
},
"resume_data": { "experiences": [{ "job_title": "Sales Manager", "company_name": "Example Co" }] }
},
"meta": { "schema": "leadocean.person.v1", "fetched_at": "2026-10-01T00:00:00Z" }
}Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing or wrong x-api-key. | Re-export $LEADOCEAN_API_KEY from app.leadocean.io. |
| 429 | Above the plan's rate limit: 5 requests a second on Free, 50 on Solo, 200 on Agency. | Wait for Retry-After, then retry. A sequential loop will not hit it. |
| 402 | Out of records. On Free this is a refusal after your 1,000. | Check GET /v1/account, shrink the batch, or move to Solo ($97 a month). |
| 404 on enrich | We hold nothing for that person. Not billed. | Mark the row a miss and retry at its tier age. |
| Empty results | A filter value that is not an exact enum string, such as vp where the filter wants VP. | List valid values with GET /v1/enums or the leadocean_list_enum_values tool. |
FAQ
How accurate is enriched data?
It depends on the field and on the age of the record. Titles and employers change faster than company domains. Read email_status and the record's fetched_at date instead of trusting an average.
How often should I re-enrich?
Monthly at the fastest, because the LeadOcean dataset refreshes monthly. Use 30 days for open deals, 90 for active leads and 180 for the rest.
Does LeadOcean refund bounced emails?
No. Read email_status before every send and drop anything that is not verified, catch_all_valid or catch_all.
Can I size a list before I spend records?
Yes. count=true with limit=1 on people search is free, and so is the MCP tool leadocean_count_leads. For companies, GET /v1/companies/count is free too.
What does this cost?
The counts spend nothing. The Free plan covers the whole walkthrough inside its 1,000 records. See pricing for Solo ($97 a month) and Agency ($497 a month), and the lead enrichment tools comparison for how other vendors handle the same six problems. Startups can start with the startup guide. Recruiters have their own, the recruiter guide.
Check your enrichment accuracy on 1,000 free records
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →