B2B data cleansing means fixing what is already in your database: duplicates, bad formats, dead emails and people who changed jobs. Do it in this order: dedupe locally, check a sample against a source of truth, drop bad addresses, then replace what you removed. This guide runs all five steps on a CSV export.
Examples use placeholders (Acme, acme.com, Jane Doe). Swap in your own rows.
TL;DR
- Cleanse before you enrich. Dedupe and normalize first, so you never pay to look up the same person twice.
- Measure on a sample. Check 200 rows, read the stale rate, then decide whether to patch rows or re-pull the list.
- Takes about 45 minutes and spends up to 200 of the 1,000 free records. Counts and misses cost nothing.
Cleansing is not enrichment. Cleansing removes and corrects. Enrichment adds fields you lack. The first is cheap and local. The second spends records.
Prerequisites
- A CRM export as CSV with at least
email,full_nameandcompany_domaincolumns. - A LeadOcean key from app.leadocean.io. The Free plan works: no card, 1,000 records for life, 5 requests a second.
- The key in your shell:
export LEADOCEAN_API_KEY=... - Python 3,
curlandjq.
All requests go to https://api.leadocean.io with the header x-api-key.
Step 1: Dedupe and normalize locally
Most duplicates are visible without any external data. Lowercase the email, strip the domain, and keep one row per email.
import csv
def norm_domain(d):
d = (d or "").strip().lower()
for p in ("https://", "http://", "www."):
d = d.removeprefix(p)
return d.split("/")[0]
seen, rows = set(), []
for r in csv.DictReader(open("crm_export.csv")):
r["email"] = (r["email"] or "").strip().lower()
r["company_domain"] = norm_domain(r["company_domain"])
if not r["email"] or r["email"] in seen:
continue
seen.add(r["email"])
rows.append(r)
print(len(rows), "rows after dedupe")
w = csv.DictWriter(open("step1.csv", "w", newline=""), fieldnames=rows[0].keys())
w.writeheader(); w.writerows(rows)This catches exact repeats only. The same person with a personal and a work address stays as two rows until Step 2 shows both point to the same person.
Step 2: Check a sample against the source
Pull a random 200 rows and enrich each by email. Enrichment returns the person's current employer, so you can see who has moved.
curl -s -X POST https://api.leadocean.io/v1/people/enrich \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"email":"jane.doe@acme.com","reveal_email":true}' \
| jq '{employer: .data.contact_data.contact_current_experiences[0].company_domain,
status: .data.contact_data.email_status,
type: .data.contact_data.email_type}'Send exactly one of linkedin_url, email, phone or person_id per call. A person LeadOcean holds nothing for is a 404 and costs nothing. Count those too: they are rows you cannot confirm.
Now compare. A row is stale when the employer domain no longer matches your CRM.
def classify(crm_domain, resp):
if resp is None:
return "unmatched"
exp = resp["data"]["contact_data"]["contact_current_experiences"]
now = exp[0]["company_domain"] if exp else None
return "ok" if now == crm_domain else "moved"Three numbers matter: the share moved, the share unmatched, and (next step) the share with a bad email status.
Step 3: Drop addresses you should not send to
Email status is the cleanest cleansing signal, and it comes back in the same enrichment call. Keep the statuses you chose. Remove the rest.
KEEP = {"verified"} # strictest
MAYBE = {"catch_all_valid", "catch_all"} # accepts any address: cannot be proven
DROP = {"invalid", "disposable", "spam_trap", "abuse", "role", "risky",
"unknown", "untested", "derived", "none"}
def email_action(status, email_type):
if email_type == "work_other":
return "drop" # usually a former employer's mailbox
if status in KEEP: return "keep"
if status in MAYBE: return "review"
return "drop"work_other means the address belongs to a former employer. It is the address most likely to bounce. LeadOcean does not refund bounces, so decide before you send.
Step 4: Size the replacement list for free
Rows you dropped need replacing. Describe the segment they came from and count it first. A count spends no records.
curl -s -X POST "https://api.leadocean.io/v1/people/search?count=true&limit=1" \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"jobLevel":["Director"],"jobFunction":["Finance & Accounting"],
"country":["US"],"emailStatus":["verified"],"emailType":["work"]}' \
| jq '.meta | {total, totalIsExact, distinctCompanies}'Send count=true as a query parameter, not in the JSON body. Enum values are exact strings: Director is a job level and director is a seniority. If you work through the MCP server, leadocean_count_leads does the same job and leadocean_list_enum_values checks the strings. Both are free.
Step 5: Pull the replacements
Once the count looks right, export the rows. One request takes up to 50,000 rows and a day allows 500,000.
curl -s https://api.leadocean.io/v1/exports \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Replacements: US finance directors",
"filters": {"jobLevel": ["Director"], "jobFunction": ["Finance & Accounting"],
"country": ["US"], "emailStatus": ["verified"], "emailType": ["work"]},
"limit": 500,
"preset": "full"
}'Poll GET /v1/exports/{id}, then fetch GET /v1/exports/{id}/download. Every row costs 1 record. Bulk CSV export is not on the Free plan: it starts on Solo at $97 a month. On Free, page through /v1/people/search with cursor instead.
The Exports page in the app does the same with a filter builder and shows the record price before you start. Then merge on email, and cleanse again in a month. The dataset refreshes monthly, and each record carries its own fetched_at date.
What you get
Counts below were run on 2026-10-21 with the free leadocean_count_leads tool for jobLevel Director, jobFunction Finance & Accounting, country US. No rows were returned.
| Filter | People |
|---|---|
| No email filter | 727,906 |
Default mailable (verified, catch_all_valid, catch_all) | 242,200 |
emailStatus: verified, emailType: work | 49,980 |
The same segment is 728K people, 242K mailable by the default definition, and 50K if you accept only verified work addresses. Pick the row you build the list on. The 49,980 verified work addresses span 28,433 distinct companies.
Response shape from the API reference (placeholder values), for the enrichment in Step 2:
{
"success": true,
"data": {
"profile_data": { "profile_full_name": "Jane Doe" },
"contact_data": {
"contact_emails": [
{ "email": "jane.doe@acme.com", "type": "work", "status": "verified",
"verified_batch_date": "2026-09-20" }
],
"has_email": true,
"email_status": "verified",
"email_type": "work",
"contact_current_experiences": [
{ "company_name": "Acme", "company_domain": "acme.com", "job_title": "Finance Manager" }
]
}
},
"meta": { "schema": "leadocean.person.v1" }
}Read verified_batch_date too. A date means LeadOcean ran the check. A null means the status came from another source held to the same standard. Re-verify before a very large send.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing or wrong x-api-key header | Re-export $LEADOCEAN_API_KEY and check the header name |
| 429 | Requests above the plan's rate limit (5 a second on Free, 200 on Agency) | Honor Retry-After, or batch with a short sleep |
| 402 | Records spent (Free is 1,000 for life) | Check GET /v1/account, then move to a paid plan on the pricing page |
| Empty results | Wrong enum string, such as director as a job level | Fetch the allowed values from GET /v1/enums first |
| 404 on enrich | No person matched that email | Expected and free. Count it as unmatched |
| Export refused | Over 50,000 rows a request or 500,000 a day | Split the export by country or level |
FAQ
What is the difference between data cleansing and data enrichment?
Cleansing fixes records you already hold: duplicates, formats, dead emails. Enrichment adds missing fields. Cleanse first, so you do not spend records filling in rows you will delete.
How often should I cleanse B2B data?
Monthly for lists you send to. The LeadOcean dataset refreshes monthly, so a monthly pass matches the source. Run the Step 2 sample each time and watch the stale rate.
Should I patch stale rows or re-pull the list?
Patch below roughly 10 percent stale, re-pull above it. That threshold is a rule of thumb, not a LeadOcean figure. Your sample gives you the real number.
Does cleansing cost records?
Steps 1, 3 and 4 spend none. Step 2 spends one record per matched row. Step 5 spends one per exported row. Counts, 404s and empty pages are free.
How does this connect to deliverability?
Dropping bad addresses protects your sender reputation. If you also warm new mailboxes, see inbox warming and inbox warm-up. To compare list sources, see email marketing databases. The wider framework is in the B2B data quality guide.
Cleanse your first 200 rows against live data
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →