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

How-to guide

How to Cleanse B2B Data (A CRM Export, Step by Step)

Dedupe, re-check and replace the records in a CRM export. Real code, a real count, and the response shape.

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

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_name and company_domain columns.
  • 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, curl and jq.

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.

python
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.

bash
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.

python
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.

python
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.

bash
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.

bash
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.

FilterPeople
No email filter727,906
Default mailable (verified, catch_all_valid, catch_all)242,200
emailStatus: verified, emailType: work49,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:

json
{
  "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

ErrorCauseFix
401Missing or wrong x-api-key headerRe-export $LEADOCEAN_API_KEY and check the header name
429Requests above the plan's rate limit (5 a second on Free, 200 on Agency)Honor Retry-After, or batch with a short sleep
402Records spent (Free is 1,000 for life)Check GET /v1/account, then move to a paid plan on the pricing page
Empty resultsWrong enum string, such as director as a job levelFetch the allowed values from GET /v1/enums first
404 on enrichNo person matched that emailExpected and free. Count it as unmatched
Export refusedOver 50,000 rows a request or 500,000 a daySplit 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 →