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,jqand Python 3 withrequests.- 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.
"""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 thisverified 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.
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.
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.jsonlThe 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.
"""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.
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] += 1Send 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.
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.
| Number | How to get it | What to do when it rises |
|---|---|---|
| Hard bounce rate | Bounces divided by sends, from your sequencer's report | Tighten SEND in Step 1 and re-run Step 4 on the rows that bounced. |
| Hold-to-send ratio | len(hold) / len(send) from gate.py | A ratio that climbs means the filter is pulling in untested rows. Add emailStatus to the search body. |
| Stale share | Rows older than MAX_AGE_DAYS | Re-enrich them before the wave, not after. |
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.
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_DAYSA 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.
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.
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: 0The 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.
{
"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
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing or wrong x-api-key header | Check $LEADOCEAN_API_KEY is exported and the header is lowercase x-api-key. |
| 402 | Free plan only: the 1,000 one-off records are spent | Size with count=true first. Move to Solo at $97 or Agency at $497 a month. |
| 429 | Above 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 match | A bad enum value, such as vp where the list wants VP | Look up the exact string with GET /v1/enums/{name}. |
meta.total stops at 100,000 | The count is capped | Treat it as a floor and narrow the filters. |
| 400 or 429 on an export | Over 50,000 rows in one request, or 500,000 in a day | Split 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 →