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

How-to guide

How to Build for Email Deliverability (a Gate Between Your Data and Your Sequencer)

Deliverability is decided before the send. Put one function between your list and your sequencer and let only checked addresses through.

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

You build for deliverability by putting one gate between your list and your sequencer. The gate reads each address's checked status, sends the safe ones and holds or drops the rest.

This is a build guide, not a checklist. The code is about 60 lines of Python and a handful of curl calls. If you want the filter-only version, read how to filter a B2B list for deliverability. For the sending side, see how to improve email deliverability and email deliverability best practices for B2B.

TL;DR

  • Put the gate in code: size for free, filter on email_status, re-check at enrichment, then write a send list and a hold list.
  • It takes about 30 minutes and spends about 50 LeadOcean records. Sizing costs none, and the free plan has 1,000.
  • Treat a search status as an upper bound. The enrich answer is the one to trust.

Prerequisites

  • A LeadOcean API key from app.leadocean.io, exported as $LEADOCEAN_API_KEY. The free plan needs no card.
  • curl, jq and Python 3 with requests.
  • A list definition. The examples use VPs in the US.

Step 1: Write the send policy as data

The policy is one dictionary. Everything downstream reads it, so you change a rule in one place.

python
"""policy.py"""
SEND = {"verified", "catch_all_valid"}
HOLD = {"catch_all", "risky", "role", "derived", "untested", "unknown"}
DROP = {"invalid", "spam_trap", "abuse", "disposable", "none"}
MAX_PER_DOMAIN = 3          # contacts per company domain in one wave
MAX_AGE_DAYS = 30           # re-check addresses older than this

verified means LeadOcean's own verifier sent to a business address and the server accepted it. catch_all_valid is a mailbox on a catch-all domain whose existence was confirmed. Everything in DROP will bounce or hurt your reputation.

MAX_PER_DOMAIN and MAX_AGE_DAYS are our defaults, not API limits. Change them to fit your sending volume.

Step 2: Size the list for free

A count returns a total and no rows, so it costs nothing. Send count=true and limit=1 as query parameters, and the filters in the body.

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":["VP"],"country":["US"],"emailStatus":["verified"],"emailType":["work"]}' \
  | jq '.meta'

meta.total is capped at 100,000, so a big list reads as a floor. Run the same call again with no emailStatus. The gap between the two is what the gate will keep out of your sequencer.

Enum values are exact strings. VP works for jobLevel, vp does not. Look them up with GET /v1/enums/job_level.

Step 3: Pull candidates

Search returns flags, not addresses. Each person returned costs one record, so keep limit small while you test.

bash
curl -s -X POST "https://api.leadocean.io/v1/people/search" \
  -H "x-api-key: $LEADOCEAN_API_KEY" \
  -H "content-type: application/json" \
  -d '{"jobLevel":["VP"],"country":["US"],"emailStatus":["verified"],"emailType":["work"],"limit":25}' \
  | jq -c '.data[]' > candidates.jsonl

The body is flat JSON and every filter is an array of strings. limit runs from 1 to 100. Page on with meta.nextCursor, passed back unchanged. One search stops at 10,000 rows (meta.depthCapped), so narrow the filters instead of paging harder.

Step 4: Re-check each address at enrichment

A person can be verified in a search row and untested when enriched. The search index has no per-address verification record, and the enrich answer is the correct one.

Enrich by person_id (at meta.source_ids.person_id on each search row) with reveal_email on. A person with nothing on file returns 404 and costs nothing.

python
"""gate.py"""
import json, os, requests
from policy import SEND, HOLD, DROP

API = "https://api.leadocean.io"
H = {"x-api-key": os.environ["LEADOCEAN_API_KEY"], "content-type": "application/json"}

def enrich(person_id):
    r = requests.post(f"{API}/v1/people/enrich", headers=H,
                      json={"person_id": person_id, "reveal_email": True})
    if r.status_code == 404:
        return None                      # free: nobody on file
    r.raise_for_status()
    return r.json()["data"]["contact_data"]

send, hold = [], []
for line in open("candidates.jsonl"):
    person_id = json.loads(line)["meta"]["source_ids"]["person_id"]
    c = enrich(person_id)
    if not c:
        continue
    status = c.get("email_status") or "none"
    if status in DROP:
        continue
    row = {"person_id": person_id, "status": status,
           "email": c["contact_emails"][0]["email"] if c.get("contact_emails") else None}
    (send if status in SEND else hold).append(row)

json.dump(send, open("send.json", "w"), indent=1)
json.dump(hold, open("hold.json", "w"), indent=1)
print(len(send), "send,", len(hold), "hold")

Each enrich call is one record. Stay under your plan's rate limit: 5 a second on Free, 50 on Solo and 200 on Agency. The script above is serial, so it sits well under all three.

Step 5: Cap contacts per domain

Six emails to one company in one morning looks like a campaign, not a conversation. Cap it before the sequencer sees the list.

python
from collections import Counter
from policy import MAX_PER_DOMAIN

seen = Counter()
capped, spill = [], []
for row in send:
    domain = row["email"].split("@")[1]
    (capped if seen[domain] < MAX_PER_DOMAIN else spill).append(row)
    seen[domain] += 1

Send capped now. Keep spill for the next wave, a week later.

Step 6: Feed bounces back as a suppression list

LeadOcean gives no refund or credit for bounced emails, so the bounce is your signal to fix the rule, not to ask for money back. Log every hard bounce and complaint to a file, and subtract it from every future list.

python
suppress = set(open("suppress.txt").read().split())
final = [r for r in capped if r["email"] not in suppress]

If a status you sent keeps bouncing, tighten SEND in Step 1. Moving catch_all_valid to HOLD is a one-line change.

Step 7: Watch three numbers per wave

A gate only works if you measure what leaves it. After each wave, log three numbers next to the wave's filters and date.

NumberHow to get itWhat to do when it rises
Hard bounce rateBounces divided by sends, from your sequencer's reportTighten SEND in Step 1 and re-run Step 4 on the rows that bounced.
Hold-to-send ratiolen(hold) / len(send) from gate.pyA ratio that climbs means the filter is pulling in untested rows. Add emailStatus to the search body.
Stale shareRows older than MAX_AGE_DAYSRe-enrich them before the wave, not after.
python
import csv, datetime
with open("waves.csv", "a", newline="") as f:
    csv.writer(f).writerow([datetime.date.today(), len(final), len(hold),
                            round(len(hold) / max(len(final), 1), 2)])

Keep the log in a plain CSV and add the bounce rate by hand once the wave ends. Next month you compare two rows, and you see which rule change moved the bounce rate. See the 2026 deliverability benchmarks for how your numbers compare.

Do not widen the filter to hit a volume target. Adding risky or untested grows the list and grows the bounce rate with it. A smaller list that lands protects the sending domain you have spent weeks warming.

Step 8: Re-check before every new wave

Data goes stale as people change jobs. The dataset refreshes monthly and each record carries its own fetched_at date. Before a new wave, re-run Step 4 on any row older than MAX_AGE_DAYS.

Read the age from the record's fetched_at date and queue the old rows for another pass.

python
import datetime
from policy import MAX_AGE_DAYS

def is_stale(fetched_at):
    age = datetime.date.today() - datetime.date.fromisoformat(fetched_at[:10])
    return age.days > MAX_AGE_DAYS

A stale row is not a bad row. It is a row whose status you have not seen this month, and a month is long enough for a mailbox to close.

For a full list, export instead of enriching row by row. Bulk CSV export is included on Solo and above, not on Free. One request takes up to 50,000 rows and an account takes 500,000 a day.

bash
curl -s -X POST "https://api.leadocean.io/v1/exports" \
  -H "x-api-key: $LEADOCEAN_API_KEY" \
  -H "content-type: application/json" \
  -d '{"name":"us-vp-verified","filters":{"jobLevel":["VP"],"country":["US"],"emailStatus":["verified"],"emailType":["work"]},"limit":5000}' \
  | jq '.data | {id, status, requested}'

The CSV carries numbered headers such as email_1_address and email_1_status. Run the same status check on email_1_status before the file goes anywhere. The Exports page in the app has the same filter builder and shows the record price before you start.

What you get

We ran Step 2 through the free leadocean_count_leads MCP tool on 21 October 2026. The filters were jobLevel VP and country US.

text
emailStatus verified:                              257,840 people
default (verified, catch_all_valid, catch_all):  1,242,801 people
no email condition (totalAll):                   3,102,417 people
credits spent: 0

The first number is an upper bound until enrichment confirms each address. The second is the mailable count. Without the gate, about three in five people in that list have no address worth sending to.

A revealed address from enrichment has this shape. Response shape from the API reference, placeholder values.

json
{
  "success": true,
  "data": {
    "contact_data": {
      "has_email": true,
      "email_status": "verified",
      "email_type": "work",
      "contact_emails": [
        { "email": "jane.doe@acme.com", "type": "work", "status": "verified" }
      ]
    }
  }
}

The script then prints one line, such as 18 send, 4 hold. Your split will differ with the list.

Troubleshooting

ErrorCauseFix
401Missing or wrong x-api-key headerCheck $LEADOCEAN_API_KEY is exported and the header is lowercase x-api-key.
402Free plan only: the 1,000 one-off records are spentSize with count=true first. Move to Solo at $97 or Agency at $497 a month.
429Above your plan's rate limit on one key (5 a second on Free, 200 on Agency)Honor Retry-After and add a short sleep to the loop.
Empty data on a filter that should matchA bad enum value, such as vp where the list wants VPLook up the exact string with GET /v1/enums/{name}.
meta.total stops at 100,000The count is cappedTreat it as a floor and narrow the filters.
400 or 429 on an exportOver 50,000 rows in one request, or 500,000 in a daySplit the export by country or seniority and run the rest tomorrow.

FAQ

Does a clean list guarantee inbox placement?

No. Placement also depends on your domain reputation, authentication and content. A clean list removes the part you can fix with data, which is the bounce rate.

Should I send to catch-all addresses?

Only in a small, separate batch. A catch_all domain accepts any recipient, so the mailbox cannot be confirmed. catch_all_valid is the exception: that mailbox was confirmed to exist.

Why does the search status differ from the enrich status?

The search index carries no per-address verification record. The enrich answer applies the narrow rule to the single address, so it is the correct one.

Does LeadOcean verify personal emails?

No. It does not verify consumer domains, so verified on a personal address means a supplier asserted it. Treat it as a lead to check.

How many records does this cost?

Sizing is free. Search costs one record per person returned and enrich costs one per call. This build spends about 50.

Put the gate in front of your next send

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

Get your free API key →