You filter a list for deliverability by sizing it for free, requiring a checked email_status, and confirming each address when you enrich it. This guide does that with five requests against the LeadOcean API.
Deliverability and verification are two different jobs. Verification asks whether a mailbox exists. Deliverability asks whether your message reaches an inbox. Verification is the part your data provider owns, so start there.
Related: the email deliverability best practices for B2B, the SMTP verification glossary entry, a comparison of email verification tools, and the pricing page. The hub for this section is the LeadOcean blog.
TL;DR
- Filter on
emailStatusandemailTypein the search body, and size the list first. Sizing is free. - Treat a search status as an upper bound. Confirm each address with enrichment before it goes into a sequence.
- The whole walkthrough takes about 20 minutes and spends about 30 records. Your free plan has 1,000.
Prerequisites
- A LeadOcean API key from app.leadocean.io, exported as
$LEADOCEAN_API_KEY. The free plan needs no card. curl, and Python 3 for the routing script in step 5.- A target list in mind. The examples use VPs of sales in Canada.
Step 1: Size the list for free
A sizing call returns a total and no rows, so it costs nothing however often you run it. Send count=true and limit=1 as query parameters.
curl -G https://api.leadocean.io/v1/people/search \
-H "x-api-key: $LEADOCEAN_API_KEY" \
--data-urlencode "jobLevel=VP" \
--data-urlencode "jobFunction=Sales & Business Development" \
--data-urlencode "country=CA" \
--data-urlencode "emailStatus=verified" \
--data-urlencode "emailType=work" \
-d "count=true" -d "limit=1"Read meta.total. It is capped at 100,000, and meta.totalIsExact tells you if the number is a floor. Run it again with emailStatus=catch_all and then with no emailStatus at all. The gaps between the three totals are your deliverability risk, before you spend a record.
Compare the three totals before you pull anything. If verified is a third of the unfiltered total, a plain export would put two bad addresses in every three rows. That is the case for filtering at the source, not cleaning afterwards.
Enum values are exact strings. VP works, vp does not on jobLevel. Check the closed lists with GET /v1/enums/{name} if a filter returns nothing.
Step 2: Filter on email status and type
Pick the statuses you accept. Safe to send is verified and catch_all_valid. A catch_all address sits on a domain that accepts every recipient, so no check can confirm the mailbox. Keep those in a separate batch.
Pair emailStatus with emailType so you get a deliverable work address, not a personal one.
{
"jobLevel": ["VP"],
"jobFunction": ["Sales & Business Development"],
"country": ["CA"],
"emailStatus": ["verified"],
"emailType": ["work"],
"limit": 25
}The search body is flat JSON. Filters are arrays of strings. limit runs from 1 to 100.
Do not widen emailStatus to chase volume. Adding risky or untested grows the list and also grows your bounce rate. A smaller list that lands is worth more than a larger one that damages your sending domain.
Step 3: Pull a page and read the status
Send the body to POST /v1/people/search. Each person returned costs one record, so keep limit small while you test.
curl -X POST https://api.leadocean.io/v1/people/search \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"jobLevel":["VP"],"jobFunction":["Sales & Business Development"],"country":["CA"],"emailStatus":["verified"],"emailType":["work"],"limit":25}'Search rows carry flags, not addresses. Read contact_data.email_status and contact_data.email_type on each row. Page on with meta.nextCursor, and pass it back unchanged.
A search stops issuing cursors after 10,000 rows by default. Narrow the filters and run several smaller searches instead of paging harder.
Step 4: Confirm each address at enrichment
The search index has no per-address verification record, so a person can be verified in a search row and untested when enriched. The enriched answer is the correct one.
Enrich by person_id, the value at meta.source_ids.person_id on the search row. Set reveal_email to get the address.
curl -X POST https://api.leadocean.io/v1/people/enrich \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"person_id":"123456789","reveal_email":true}'Each call costs one record. A person with nothing on file returns 404 and costs nothing. Send exactly one identifier: linkedin_url, email, phone or person_id.
For just the work address, GET /v2/people/email/work?person_id=123456789 returns one checked address or data: null. It costs one record per call, including a call that finds nothing.
Each address carries verified_batch_date. A date means LeadOcean's own verifier tested it. A null means the check came from a source held to the same standard, not from a test of ours.
Step 5: Route by status
Send what is safe, hold what is uncertain, drop the rest. This script reads contact_data.email_status from each enriched record. It makes no API calls.
import json, sys
SEND = {"verified", "catch_all_valid"}
HOLD = {"catch_all", "risky", "role", "derived", "untested", "unknown"}
DROP = {"invalid", "spam_trap", "abuse", "disposable", "none"}
send, hold, drop = [], [], []
for line in sys.stdin:
person = json.loads(line)
contact = person["data"]["contact_data"]
status = contact.get("email_status") or "none"
bucket = send if status in SEND else hold if status in HOLD else drop
bucket.append({"status": status, "email_type": contact.get("email_type")})
print(len(send), "send,", len(hold), "hold,", len(drop), "drop")Pipe one enrich response per line into it. The table below is the policy it encodes.
| email_status | Meaning | Action |
|---|---|---|
verified, catch_all_valid | LeadOcean's verifier sent to it and the server accepted, or the catch-all mailbox was confirmed to exist | Send |
catch_all | The domain accepts everything | Hold for a small, separate batch |
risky, role, derived | Shared mailbox, or built from a company pattern and never tested | Hold, or verify elsewhere |
untested, unknown | Not checked, or inconclusive | Hold |
invalid, spam_trap, abuse, disposable | Will bounce or harm reputation | Drop |
Do not expect a refund or credit for bounced emails. Read email_status before you send, not after.
For a full list, export instead of enriching row by row. Bulk CSV export is included on Solo and above, not on Free. The app's Exports page has the same filter builder with a record price preview.
curl -X POST https://api.leadocean.io/v1/exports \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"vp-sales-ca-verified","filters":{"jobLevel":["VP"],"country":["CA"],"emailStatus":["verified"],"emailType":["work"]},"limit":100,"columns":["person_id","profile_full_name","current_company_domain","email_N_address","email_N_status"]}'The N columns expand to email_1_address, email_1_status and so on in the CSV. One export takes up to 50,000 rows, and an account takes 500,000 a day. Poll GET /v1/exports/{id}, then download from /v1/exports/{id}/download.
Bounces are the cost of skipping this. Mailbox providers watch your bounce rate and your spam complaints, and a domain that bounces often is filtered more often. Filtering at the data source is the cheapest fix because it happens before you send. Warm-up, authentication and sending volume are separate jobs, covered in the deliverability best practices guide.
What you get
The status vocabulary is the contract. This is the free leadocean_list_enum_values MCP tool answering for email_status on 21 October 2026, trimmed to the values and labels.
{
"name": "email_status",
"total": 13,
"values": ["verified","catch_all_valid","catch_all","risky","unknown","untested",
"invalid","role","disposable","spam_trap","abuse","derived","none"],
"labels": {
"verified": "Deliverable",
"catch_all_valid": "Catch-all, mailbox confirmed",
"catch_all": "Catch-all domain",
"risky": "Risky",
"unknown": "Unknown",
"untested": "Not yet checked",
"invalid": "Undeliverable",
"role": "Role address",
"disposable": "Disposable address",
"spam_trap": "Spam trap",
"abuse": "Abuse / complainer",
"derived": "Derived, not verified",
"none": "No email"
}
}The same answer marks verified and catch_all_valid as safe to send. catch_all_valid is a mailbox on a catch-all domain whose existence was confirmed through the provider's identity check. As a filter, catch_all matches both catch_all and catch_all_valid.
We ran Step 1 through the free leadocean_count_leads MCP tool on 21 October 2026. VPs of sales in Canada gave three numbers.
emailStatus verified, emailType work: 1,602 people
default (verified, catch_all_valid, catch_all): 5,574 people
no email condition (totalAll): 18,800 people
credits spent: 0The first is a verified-only count, and as a search count it is an upper bound until enrichment confirms each address. The second is the mailable count. The gap to the third is the risk you filter out.
A sizing call over REST answers with an empty data array. This is the response shape from the API reference, with placeholder values.
{
"success": true,
"data": [],
"meta": {
"countOnly": true,
"total": 0,
"totalIsExact": true
}
}A revealed address from enrichment follows the same shape. The values below are placeholders.
{
"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",
"verified_batch_date": "2026-09-20"
}
]
}
}
}Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing or wrong x-api-key header | Check $LEADOCEAN_API_KEY is exported and the header name is lowercase x-api-key. |
| 402 | Free plan only: the 1,000 one-off records are spent | Size with count=true before pulling rows. Move to Solo at $97 or Agency at $497 a month. |
| 429 | Above your plan's rate on one key (5 a second on Free, 200 on Agency) | Back off and honour the Retry-After header. |
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}, or the free list_enum_values MCP tool. |
meta.total stops at 100,000 | The count is capped, and totalIsExact is false | Treat it as a floor and narrow the filters. |
| 400 or 429 on an export | Over 50,000 rows in one export (400), or 500,000 in a day (429) | Split the export by country or seniority. The daily limit resets, so run the rest later. |
FAQ
What is the difference between a verified and a catch-all email?
A verified address was tested and the mail server accepted that mailbox. A catch_all domain accepts any address, so the mailbox itself cannot be confirmed. Send to catch-alls in small batches and watch bounces.
Does LeadOcean verify personal emails?
No. LeadOcean does not verify consumer domains. On a personal address, verified means a supplier asserted it. Treat it as a lead to check, not a confirmed address.
Is the search status or the enrichment status correct?
Enrichment. The search index carries no per-address verification record, so a search count of verified is an upper bound. Enrich before you send.
Do I still need a separate email verifier?
For verified rows, usually not. For catch_all, untested and risky rows, a second check can help. Our email verification tools comparison covers the options.
How many records does this cost?
Sizing is free. A search costs one record per person returned, and each enrich call costs one. The walkthrough above spends about 30.
Check every email's status before you send
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →