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

How-to guide

How to Keep CRM Data Fresh Automatically (Monthly Re-Enrichment Job)

A 40-line script re-checks each contact by email, flags dead addresses and job changes, and hands your CRM a fix list. Free key is enough to test it.

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

Keep a CRM fresh by re-checking every contact on a schedule and acting only on what changed. This guide builds that job with one API call per contact, a diff against your export, and cron.

TL;DR

  • Export contacts with an email, enrich each by email, compare email status and current employer, and write a CSV of changes. Setup takes about 45 minutes.
  • It spends 1 LeadOcean record per contact we hold. A contact we hold nothing for is a 404 and costs nothing.
  • Run it monthly. The dataset refreshes monthly, so a weekly run mostly re-reads the same answers.

Prerequisites

  • A LeadOcean API key in $LEADOCEAN_API_KEY. The free plan gives 1,000 records for life, no card, 5 requests a second (pricing).
  • A CRM export as contacts.csv with columns email, company, title.
  • curl, jq and bash. Any machine with cron works.

Step 1: Export the contacts you want to watch

Contacts go stale in two ways. The address stops delivering, or the person changes employer and the title on the record is wrong. Both are visible from one enrichment call, so the job checks both.

Start with contacts that have an email and an open deal or live sequence. Do not refresh your whole database on day one.

bash
head -3 contacts.csv

The file should look like this, with placeholder rows:

csv
email,company,title
jane.doe@acme.com,Acme,VP Sales
sam.lee@example.com,Example Inc,Head of Growth

Count the rows first. That number is your worst-case record spend per run.

bash
tail -n +2 contacts.csv | wc -l

Step 2: Enrich one contact by email

Send the email to POST /v1/people/enrich. It takes exactly one of linkedin_url, email, phone or person_id. Looking up by email returns the contact points by default.

bash
curl -s 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"}' \
  | jq '.data.contact_data | {email_status, email_type, current: .contact_current_experiences[0]}'

Three fields matter for freshness. email_status says if the address still delivers. email_type shows work_other when the address belongs to a former employer. contact_current_experiences[] holds the current role, with job_title, company_name and company_domain on each entry.

Match on email first: it is the identifier your CRM already holds. If a contact has a LinkedIn URL in your CRM, send linkedin_url instead.

Step 3: Loop over the file and diff

The script below reads each row, calls the API, and prints a row only when something changed. Pace it under your plan's rate limit: 5 requests a second on Free, 200 on Agency.

bash
#!/usr/bin/env bash
set -u
OUT="changes-$(date +%F).csv"
echo "email,problem,crm_company,live_company,email_status" > "$OUT"

tail -n +2 contacts.csv | while IFS=, read -r email company title; do
  resp=$(curl -s -w '\n%{http_code}' https://api.leadocean.io/v1/people/enrich \
    -H "x-api-key: $LEADOCEAN_API_KEY" -H "Content-Type: application/json" \
    -d "{\"email\": \"$email\"}")
  code=${resp##*$'\n'}; body=${resp%$'\n'*}
  [ "$code" = "404" ] && continue            # not in our data, costs nothing
  [ "$code" = "429" ] && { sleep 2; continue; }
  [ "$code" != "200" ] && { echo "stop: HTTP $code" >&2; exit 1; }

  status=$(echo "$body" | jq -r '.data.contact_data.email_status // "none"')
  type=$(echo "$body"   | jq -r '.data.contact_data.email_type // ""')
  live=$(echo "$body"   | jq -r '.data.contact_data.contact_current_experiences[0].company_name // ""')

  problem=""
  case "$status" in invalid|spam_trap|abuse|disposable) problem="dead_email";; esac
  [ "$type" = "work_other" ] && problem="left_company"
  [ -n "$live" ] && [ "$live" != "$company" ] && problem="${problem:+$problem+}company_changed"

  [ -n "$problem" ] && echo "$email,$problem,$company,$live,$status" >> "$OUT"
  sleep 0.25
done
echo "wrote $OUT"

A company name that differs only by spelling will flag a false change. Review the first run by hand before trusting it. On a 429 the script skips that row until the next run. Lower the pace if you see many.

Save it as refresh.sh and run chmod +x refresh.sh.

Step 4: Schedule it

Run it once a month with crontab -e, after the dataset refresh. Each record also carries its own fetched_at date, so you can see how old an answer is.

bash
0 6 3 * * cd /opt/crm-refresh && LEADOCEAN_API_KEY="$(cat .key)" ./refresh.sh >> refresh.log 2>&1

Keep the key in a file with chmod 600, not in the crontab line you paste into a ticket.

Step 5: Hand the fix list to your CRM

The output is a CSV, so any CRM import takes it. Map dead_email to "email invalid, pause sequences", left_company to "open a task for the account owner", and company_changed to "review, possible champion move". The job tells you who changed. Your CRM rules decide what happens next.

Before you act on a row, check three cases:

  • Catch-all domains. A catch_all status means the server accepts any address, so the check cannot confirm the mailbox. Treat it as unknown, not dead.
  • Role addresses. A role status such as a shared inbox will not follow a person to a new job. Do not flag it as a job change.
  • No bounce refunds. We do not credit back records for bounced emails. Read email_status before you send.

To move the whole loop into a workflow tool, the n8n integration shows the same HTTP Request pattern with a schedule trigger.

Step 6: Replace the people who left

A left_company row means your account lost its contact, not that the account is dead. Pull the current people at that company and pick a replacement.

bash
curl -s "https://api.leadocean.io/v1/companies/acme.com/people?limit=25" \
  -H "x-api-key: $LEADOCEAN_API_KEY" | jq '.data | length'

This route lists people at one domain and stops at 10,000 rows. Each person returned counts one record, so keep limit small. To filter by seniority instead, use people search, where enum values are exact strings. Check them with leadocean_list_enum_values or GET /v1/enums first. Do this only for accounts with an open deal. Replacing every lost contact is how a refresh job gets expensive.

What you get

A changes-YYYY-MM-DD.csv with only the rows that need a human.

The enrichment response behind each row has this shape. This is the response shape from the API reference, with placeholder values.

json
{
  "success": true,
  "data": {
    "contact_data": {
      "has_email": true,
      "email_status": "verified",
      "email_type": "work",
      "contact_current_experiences": [ { "job_title": "VP Sales", "company_name": "Acme", "company_domain": "acme.com" } ]
    },
    "resume_data": { "experiences": [ ] }
  },
  "meta": { "source": "leadocean" }
}

A changes file looks like this.

csv
email,problem,crm_company,live_company,email_status
jane.doe@acme.com,company_changed,Acme,Example Inc,verified
sam.lee@example.com,dead_email,Example Inc,Example Inc,invalid

Both rows are placeholders, not real people.

Troubleshooting

ErrorCauseFix
401Missing or wrong x-api-key headerCheck $LEADOCEAN_API_KEY is exported in the cron environment
429Above the plan's rate limit (5 a second on Free, 200 on Agency)Raise the sleep; the response carries Retry-After
402Out of records on the free planCut the contact list, or move to Solo at $97 a month
404We hold nothing for that emailExpected. It costs nothing; keep the CRM row as is
Many rows flag company_changedName spelling differs between CRM and our dataCompare by domain instead of name for the next run

FAQ

How often should I refresh CRM data?

Monthly. The dataset is refreshed monthly, and each record carries its own fetched_at date, so a faster schedule rarely finds new answers.

How many records does a run spend?

One per contact we hold, so at most your row count. Contacts we hold nothing for are a 404 and cost nothing. The free 1,000 records are one-off, so use them to prove the diff once.

Contacts watchedRecords per monthly run (worst case)Fits
500500Free, once
5,0005,000Solo at $97 a month
50,00050,000Agency at $497 a month

Paid plans have a fair-use pace, not credits. Reaching it slows the key until the window resets. Solo allows 10k enrichments every 5 hours and 50k a week, so a 5,000-contact run fits in one window.

Does this fix duplicates or formatting?

No. It catches decay: dead addresses and job moves. For duplicates and field cleanup, see CRM data hygiene.

Which paid plan fits a recurring job?

Solo at $97 a month suits one person with a few thousand contacts. Agency at $497 a month suits teams that refresh client databases. See pricing, and compare other tools in best CRM data enrichment tools.

For the wider picture, read CRM data enrichment: keep your CRM fresh and accurate.

Run your first CRM refresh on the free plan

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

Get your free API key →