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

How-to guide

How to Take CRM Data Hygiene to a Clean Database (With the LeadOcean API)

Dedupe an export, re-check every contact, bucket them into keep, review and drop, then backfill the accounts where the contact left.

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

CRM hygiene is a four-part job: remove duplicates, drop dead addresses, flag people who changed jobs, and fill the gaps. This recipe does all four from a CSV export and a shell. No CRM plugin is involved.

You enrich each contact by email. That one call returns the email status, the email type and the current employer. A contact we hold nothing for is a 404 and costs nothing.

TL;DR

  • Export contacts to CSV, dedupe locally, enrich each unique email once, then sort every contact into keep, review, moved on or drop.
  • It takes about 45 minutes for 500 contacts. It spends up to 500 records, plus about 10 for the spot-check and the backfill.
  • Misses are free, so the real cost is the number of contacts we hold. The free plan (1,000 records, one-off) covers the example.

Prerequisites

  • A CRM that exports contacts to CSV, with the email in the first column.
  • A LeadOcean API key from app.leadocean.io. Free keys get both the search and enrich scopes.
  • The key in your shell as $LEADOCEAN_API_KEY. You also need curl and jq.
bash
export LEADOCEAN_API_KEY="paste-your-key-here"

Step 1: Dedupe the export (free, local)

Duplicates cost you records if you enrich them twice, so remove them first. Lowercase every address, trim spaces, drop anything that is not shaped like an email, and keep one of each.

bash
tail -n +2 crm_contacts.csv \
  | cut -d, -f1 | tr -d '"' | tr 'A-Z' 'a-z' | sed 's/^ *//;s/ *$//' \
  | grep -E '^[^@ ]+@[^@ ]+\.[^@ ]+$' | sort -u > emails.txt
wc -l < emails.txt

Compare that count with the row count of the export. The gap is your duplicate and junk-address rate. Fix that in the CRM with a merge, not here.

Step 2: Check your balance

GET /v1/account is free. It shows your plan and how many records are left, so you know whether the run fits before you start.

bash
curl -s https://api.leadocean.io/v1/account \
  -H "x-api-key: $LEADOCEAN_API_KEY" | jq '.data.records | {used, remaining}'

If remaining is below the line count of emails.txt, run the first part of the file now and the rest later. A free account does not refill, so the 402 in the troubleshooting table is final.

Step 3: Enrich one contact and read the answer

Enrich by email with POST /v1/people/enrich. Send exactly one identifier. Send reveal_email: false: you want the verdict and the role, not new addresses. Left out, it defaults to true on an email lookup. The cost is one record either way.

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":false}' \
  | jq '.data.contact_data | {email_status, email_type,
      role: .contact_current_experiences[0] | {job_title, company_name, company_domain}}'

Three fields drive the whole recipe:

  • email_status says whether the address is deliverable.
  • email_type of work_other means the address belongs to a former employer. That is the likeliest sign the person moved on.
  • contact_current_experiences[0] is the role they hold now.

The same record also carries resume_data.experiences[], the person's past roles with company names and dates. Use it to confirm a move: the CRM's old employer should appear there with an end date. Past employers carry no company_id yet, so match them on name.

Step 4: Enrich the whole list

Loop over emails.txt and write one JSON line per contact. Branch on the HTTP code. 200 is a hit and costs one record. 404 means we hold nothing and costs nothing. 402 or 429 stops the run so you can read the error.

bash
: > enriched.jsonl
while read -r email; do
  resp=$(curl -s -w '\n%{http_code}' -X POST https://api.leadocean.io/v1/people/enrich \
    -H "x-api-key: $LEADOCEAN_API_KEY" -H "content-type: application/json" \
    -d "$(jq -n --arg e "$email" '{email:$e, reveal_email:false}')")
  code=${resp##*$'\n'}; body=${resp%$'\n'*}
  case $code in
    200) echo "$body" | jq -c --arg e "$email" '
           ((.data.contact_data.contact_current_experiences // [])[0] // {}) as $r
           | {crm_email:$e, found:true,
              status:.data.contact_data.email_status,
              type:.data.contact_data.email_type,
              title:$r.job_title, company:$r.company_name, domain:$r.company_domain}' >> enriched.jsonl ;;
    404) jq -nc --arg e "$email" '{crm_email:$e, found:false}' >> enriched.jsonl ;;
    *)   echo "stopped at $email with HTTP $code" >&2; break ;;
  esac
done < emails.txt
wc -l enriched.jsonl

This runs one request at a time, under the per-account rate of every plan (5 a second on Free, 50 on Solo, 200 on Agency). For 500 contacts expect a few minutes, depending on your connection.

Step 5: Bucket every contact

Turn the status and type into one action per contact. The order matters: a dead address is a drop even when the person has moved.

bash
jq -s 'map(. + {action:
  (if (.found|not) then "no_match"
   elif (.status|IN("invalid","spam_trap","abuse","disposable")) then "drop"
   elif .type=="work_other" then "moved_on"
   elif (.status|IN("risky","role","derived","unknown","none")) then "review"
   elif .status=="untested" then "untested"
   else "keep" end)})' enriched.jsonl > classified.json

jq -r 'group_by(.action)[] | "\(.[0].action)\t\(length)"' classified.json

What each bucket means in practice:

  • keep: verified, catch_all_valid or catch_all. Safe to send to.
  • drop: invalid, spam_trap, abuse, disposable. Suppress them.
  • moved_on: the address is a former employer's. Find the replacement in Step 6.
  • review: risky, role (a shared mailbox such as info@), derived, unknown or no email. Your call.
  • untested: nobody has checked it yet. That is not a negative signal, but verify before a big send.
  • no_match: we hold nothing. Check these by hand or leave them.

Load classified.json back into your CRM with its own import-and-update-by-email feature. Write the action into a custom field so you can filter on it.

Step 6: Backfill accounts where the contact left

A moved_on contact leaves a gap at the account. List those domains, size the replacements for free, then pull a handful.

bash
jq -r '.[] | select(.action=="moved_on") | .crm_email | split("@")[1]' classified.json | sort -u

Size one account first. A search with count=true as a query parameter and limit 1 spends no records. It returns meta.total and no rows.

bash
curl -s "https://api.leadocean.io/v1/people/search?count=true" \
  -H "x-api-key: $LEADOCEAN_API_KEY" -H "content-type: application/json" \
  -d '{"domain":["acme.com"],"jobLevel":["VP","Director"],
       "jobFunction":["Sales & Business Development"],"limit":1}' | jq '.meta'

Then pull the real rows. Each person returned is one record. Search rows carry meta.source_ids.person_id and email flags, not addresses. Enrich by person_id when you want the address.

bash
curl -s https://api.leadocean.io/v1/people/search \
  -H "x-api-key: $LEADOCEAN_API_KEY" -H "content-type: application/json" \
  -d '{"domain":["acme.com"],"jobLevel":["VP","Director"],
       "jobFunction":["Sales & Business Development"],
       "emailStatus":["verified","catch_all_valid","catch_all"],"limit":5}' \
  | jq '.meta | {count, credits, total, nextCursor}'

You can run the sizing step from an AI client too. The LeadOcean MCP server at https://api.leadocean.io/mcp has leadocean_count_leads, which is free.

text
Use leadocean_count_leads with title ["Head of Sales"], country ["US"], facets false.
Then repeat with emailStatus ["verified"]. Report both totals and say what each counted.

What you get

Step 3 returns the person record in the leadocean.person.v1 schema. This is the response shape from the API reference, with placeholder values.

json
{
  "success": true,
  "data": {
    "profile_data": { "profile_full_name": "Jane Doe" },
    "contact_data": {
      "has_email": true,
      "email_status": "verified",
      "email_type": "work",
      "contact_current_experiences": [
        { "job_title": "Head of Sales", "company_name": "Acme", "company_domain": "acme.com" }
      ]
    },
    "meta": { "schema": "leadocean.person.v1", "fetched_at": "<date>" }
  },
  "meta": { "credits": 1 }
}

Step 4 writes one line per contact. This is the shape of those lines, with placeholder values.

json
{"crm_email":"jane.doe@acme.com","found":true,"status":"verified","type":"work","title":"Head of Sales","company":"Acme","domain":"acme.com"}
{"crm_email":"john.roe@example.com","found":false}

The free MCP count from the last prompt, run on 1 October 2026, for "Head of Sales" in the US:

text
leadocean_count_leads  title ["Head of Sales"], country ["US"]
  default mailable (verified, catch_all_valid, catch_all):  5,480
leadocean_count_leads  title ["Head of Sales"], country ["US"], emailStatus ["verified"]
  verified only:                                            1,555
credits spent: 0

Read the two numbers as a range. People search cannot see per-address verification the way enrichment can. A verified count from search is an upper bound, and the enriched answer is the correct one. That is why Step 4 re-checks each contact.

Troubleshooting

ErrorCauseFix
401Missing, wrong or revoked keyRe-copy the key from app.leadocean.io and export $LEADOCEAN_API_KEY again.
402Free plan: the 1,000 one-off records are spentCheck GET /v1/account, run a shorter list, or move to Solo or Agency on pricing. Free records do not reset.
403The key lacks the enrich scopeUse a key with the scope. Free keys get both search and enrich.
429Above your plan's rate on one key (5 a second on Free, 200 on Agency)Slow the loop and wait the Retry-After seconds. Step 4 sends one request at a time.
400 or an empty searchA filter value outside the catalogue, or two identifiers on an enrich callSend one identifier. Resolve enum values with GET /v1/enums/<name>, for example Sales & Business Development.
404 on enrichWe hold nothing for that personNot an error. It costs no record. The script writes found:false.

FAQ

How many records does a CRM clean cost?

One per contact we hold, because each hit is one record. A miss is free. A 500-contact export spends at most 500, and Free has 1,000. Solo at $97 a month and Agency at $497 a month have no per-record price, only a fair-use pace. See pricing.

Can I match on name and company instead of email?

Not directly. There is no name plus domain lookup. Search by domain first, then enrich by person_id. Step 6 shows the search half.

Does a verified status guarantee no bounces?

No. LeadOcean gives no refund or credit for bounced emails, so read email_status before you send. catch_all means the domain accepts everything, so it cannot be confirmed either way.

How often should I run this?

Monthly is the shortest useful gap. The dataset is refreshed monthly, and each record carries its own fetched_at date. Run it before any large send and after a big import.

Where do I learn the basics first?

Start with what CRM hygiene is and how to enrich and clean CRM records. For account-level fields, see the parent company clean-up recipe. More recipes are in the blog hub.

Clean your CRM with 1,000 free records

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

Get your free API key →