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

How-to guide

How to Manage a B2B Email List (With a Data API)

Size the segment for free, pull it by email status, split it into send, hold and drop buckets, then re-check it every month.

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

TL;DR

  • Good list management is a status rule, not a tool. Pull only addresses with a known status, bucket them as send, hold or drop, and re-check on a schedule.
  • It takes about 30 minutes the first time. The example spends about 525 records: 500 for a pilot export and 25 for a spot re-check. The export steps need Solo ($97 a month) or above.
  • Counting, filter lookups and enum lookups cost nothing. Only people or export rows returned count as records.

Prerequisites

  • A LeadOcean account and API key from app.leadocean.io. The free plan has no card and 1,000 records, one-off.
  • The key as $LEADOCEAN_API_KEY. Exports with email columns need a key with the enrich scope. Free keys get both scopes.
  • curl, jq and Python 3. The bucketing script uses only the standard library.
bash
export LEADOCEAN_API_KEY="paste-your-key-here"

Step 1: Size the segment for free

Define one segment as filters before you pull anything. This example is VP-level sales leaders in Canada. Count them first, and count only what you can mail.

The MCP tool leadocean_count_leads does this. By default it counts people whose best email is verified, catch_all_valid or catch_all, so the total describes the mailable audience.

text
Use leadocean_count_leads with jobLevel ["VP"], jobFunction ["Sales & Business Development"],
country ["CA"], facets false. Say which email statuses it counted. Do not call any tool that spends records.

Over REST, a search with count=true as a query parameter and limit of 1 returns meta.total and no rows. It is free. The total is capped at 100,000.

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 '{
    "jobLevel": ["VP"],
    "jobFunction": ["Sales & Business Development"],
    "country": ["CA"],
    "emailStatus": ["verified", "catch_all"],
    "limit": 1
  }' | jq '.meta'

Enum values are exact strings. If a count comes back empty, resolve the value with leadocean_list_enum_values or GET /v1/enums/{name} before you change anything else.

Step 2: Pull the segment with its email status

Export the segment with the status columns, not just the address. An address without a status is a guess, and a list you cannot grade is a list you cannot manage.

An export takes the same filters plus limit and a column list. Ask for one email per person with caps, and for the columns you will manage by: person_id, the address, its status, the date it was last verified and fetched_at.

bash
curl -s https://api.leadocean.io/v1/exports \
  -H "x-api-key: $LEADOCEAN_API_KEY" -H "content-type: application/json" \
  -d '{
    "name": "CA sales VPs, pilot",
    "filters": {
      "jobLevel": ["VP"],
      "jobFunction": ["Sales & Business Development"],
      "country": ["CA"],
      "emailStatus": ["verified", "catch_all_valid", "catch_all", "risky", "role", "derived"]
    },
    "limit": 500,
    "columns": ["person_id", "profile_full_name", "current_company_domain",
                "email_N_address", "email_N_status", "email_N_verified_date",
                "contact_still_at_company_status", "fetched_at"],
    "caps": {"emails": 1}
  }' | tee export.json | jq '.data | {id, status, requested, perRow, pollAfter}'

Numbered columns are requested as email_N_... and come back as email_1_address, email_1_status and so on. The job runs in the background. Poll every pollAfter seconds, then download when the status is done.

bash
ID=$(jq -r '.data.id' export.json)
curl -s https://api.leadocean.io/v1/exports/$ID -H "x-api-key: $LEADOCEAN_API_KEY" | jq '.data.status'
curl -s https://api.leadocean.io/v1/exports/$ID/download -H "x-api-key: $LEADOCEAN_API_KEY" -o list.csv

The emailStatus filter is wide on purpose. The pilot includes the grades you will later hold or drop, so Step 3 has something to sort. In production, filter to the buckets you intend to send.

An export reserves up to limit records at once and bills each row written. A column that needs the full record can double the reservation. Bulk CSV export is not on the Free plan. It is included from Solo ($97 a month) up. On Free, page the search 100 rows at a time instead.

Step 3: Split the list into send, hold and drop

Sort every row into one of three buckets by its status. The rule is simple enough to keep in version control.

  • Send: verified and catch_all_valid. LeadOcean's own verifier sent to the address and it accepted, or the mailbox was confirmed on a catch-all domain.
  • Hold: catch_all, risky, role and derived. The domain accepts everything, or the address is shared or was never tested. Mail these in small batches, or not at all.
  • Drop: invalid, spam_trap, abuse, disposable, unknown, untested and none. Do not send to these.

Treat the verified count from search as an upper bound. The enrichment call applies the stricter per-address rule, and the enriched answer is the correct one.

python
import csv, sys
from collections import Counter

SEND = {"verified", "catch_all_valid"}
HOLD = {"catch_all", "risky", "role", "derived"}

rows = list(csv.DictReader(open("list.csv", newline="")))
seen, out, tally = set(), {"send": [], "hold": [], "drop": []}, Counter()

for r in rows:
    email = (r["email_1_address"] or "").strip().lower()
    status = (r["email_1_status"] or "none").strip()
    if not email or email in seen:
        tally["duplicate_or_empty"] += 1
        continue
    seen.add(email)
    bucket = "send" if status in SEND else "hold" if status in HOLD else "drop"
    out[bucket].append(r)
    tally[f"{bucket}:{status}"] += 1

for name, items in out.items():
    with open(f"{name}.csv", "w", newline="") as f:
        w = csv.DictWriter(f, fieldnames=rows[0].keys())
        w.writeheader(); w.writerows(items)

for k, v in sorted(tally.items()):
    print(f"{k:28} {v}")

Keep the segment definition in a file next to the script. A list nobody can reproduce is a list nobody trusts. Store the full export request body from Step 2 as segment.json and commit it with the script. When a rep asks why a person is on the list, the answer is a filter and a date.

Segment by what changes how you write, not by what is easy to export. Title level, function, country and company size are the useful cuts. Run one segment per message, and keep each small enough that you can read a sample of it.

Add your own suppression list in the same loop. Load unsubscribes, past bounces and customers into a set, and drop any row whose address or current_company_domain is in it. LeadOcean does not refund or credit back bounced emails, so the status check is your only protection.

Step 4: Re-check on a schedule

A B2B list decays because people change jobs, and addresses stop working when they do. LeadOcean refreshes the dataset monthly, and every record carries its own fetched_at date. Re-pull the segment once a month and compare it to last month by person_id.

bash
curl -s https://api.leadocean.io/v1/exports \
  -H "x-api-key: $LEADOCEAN_API_KEY" -H "content-type: application/json" \
  -d @segment.json | jq '.data.id'

Keep last month's file. The diff only works if both pulls use identical filters and columns, so re-send segment.json unchanged. Run the same export again with that file.

Then diff the two files. People missing from this month's pull left the segment. People whose status moved from verified to anything else go to hold.

python
import csv
old = {r["person_id"]: r for r in csv.DictReader(open("list_prev.csv"))}
new = {r["person_id"]: r for r in csv.DictReader(open("list.csv"))}
gone    = old.keys() - new.keys()
slipped = [p for p in old.keys() & new.keys()
           if old[p]["email_1_status"] == "verified" and new[p]["email_1_status"] != "verified"]
print(len(gone), "left the segment;", len(slipped), "lost verified status")

For a single contact you are about to mail, check one record by person_id. Enrich takes exactly one of linkedin_url, email, phone or person_id, and reveal_email returns the address.

bash
curl -s https://api.leadocean.io/v1/people/enrich \
  -H "x-api-key: $LEADOCEAN_API_KEY" -H "content-type: application/json" \
  -d '{"person_id": "<person_id from list.csv>", "reveal_email": true}' \
  | jq '.data.contact_data | {has_email, email_status, email_type}'

An enrich call for a person LeadOcean holds nothing on is a 404 and costs nothing. Check 25 rows this way before each send and you spend 25 records.

What you get

Step 1 gave a real count. On 21 October 2026 the free MCP count for VP-level sales leaders in Canada was 5,574 mailable people. The default mailable set is verified, catch_all_valid and catch_all. The same filters with no email condition match 18,800. Your number will differ as the data changes.

The status list below is the leadocean_list_enum_values result for email_status on 21 October 2026, with its labels.

text
email_status (13 values, 2026-10-21)
verified         Deliverable                   safe to send
catch_all_valid  Catch-all, mailbox confirmed  safe to send
catch_all   Catch-all domain       cannot be confirmed either way
risky       Risky                  use with care
role        Role address           shared mailbox such as info@
derived     Derived, not verified  built from the company pattern, never tested
untested    Not yet checked        nobody has checked this address
unknown     Unknown                checked, result inconclusive
invalid     Undeliverable          do not send
spam_trap   Spam trap              do not send
abuse       Abuse / complainer     do not send
disposable  Disposable address     do not send
none        No email               the person has no email

The Step 3 script prints one line per bucket and status, plus the duplicates it removed. This is the output format with placeholders, not a real run.

text
drop:<status>                <n>
duplicate_or_empty           <n>
hold:catch_all               <n>
send:verified                <n>

The export request itself returns HTTP 202 and a job. This is the response shape from the API reference, with placeholder values.

json
{
  "success": true,
  "data": {
    "id": "<export id>",
    "name": "CA sales VPs, pilot",
    "status": "queued",
    "requested": 500,
    "perRow": 1,
    "remaining": "<records left after this reservation>",
    "pollAfter": 10,
    "file": null
  }
}

Troubleshooting

ErrorCauseFix
401Missing, wrong or revoked keyRe-copy the key from app.leadocean.io and export $LEADOCEAN_API_KEY again.
429Above your plan's rate limit per account (5 a second on Free, 200 on Agency)Wait the Retry-After seconds. Send full pages of 100 rather than more calls.
402The free 1,000 records are spent, or an export limit is above your balanceRead GET /v1/account, lower limit to what remains, or move to Solo ($97 a month) or Agency ($497 a month).
400, or a count of 0A filter value outside the catalogue, such as jobFunction: "Sales"Resolve the exact string with leadocean_list_enum_values and pass it as written.
400 export_limit_rowslimit is above 50,000 rows for one exportSplit the segment by country or headcount band and run several exports.
429 export_limit_dailyMore than 500,000 export rows in a dayWait for resetsAt in the response, then run the next export.

FAQ

What is email list management?

It is the routine of building, grading, segmenting and pruning the addresses you send to. For B2B, the work is mostly status grading and re-checking, because people change jobs faster than lists get cleaned.

How often should I clean a B2B email list?

Monthly, if you send monthly. LeadOcean refreshes its dataset monthly, so a monthly re-pull catches new statuses and job changes. For a list you rarely mail, re-check the rows right before each send.

Should I send to catch-all addresses?

Not in your main send. A catch-all domain accepts every address, so nobody can confirm one is real. Send them in small batches and watch bounces, or hold them until a re-check improves the status.

Does list management cost records every month?

Counting and filter lookups are free. A monthly re-pull costs one record per row returned. Paid plans (Solo $97, Agency $497 a month) have no per-record price, only a fair-use pace, described on pricing.

What if I already have a list from somewhere else?

Enrich a sample by email and read the status. Our guide to email list churn explains why a list loses value, and the hygiene tool roundup and list provider comparison cover the alternatives.

More recipes live in the blog hub.

Grade your first B2B email list with a free API key

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

Get your free API key →