Run five checks in order, cheapest first: syntax, disposable and role filters, an MX lookup, an SMTP probe, then a stored deliverability status. Each one removes a different kind of bad address.
TL;DR
- Steps 1 to 3 run locally in a few lines and catch typos, throwaway domains and domains that cannot receive mail.
- Step 4 (SMTP) is the only way to ask a mail server about one mailbox, and it fails on catch-all domains and blocked ports. Treat it as a hint.
- Step 5 reads a status someone else already tested. Sizing an audience by status on LeadOcean spends 0 records. Total time is about 15 minutes.
Prerequisites
- A shell with
dig(ornslookup) andopenssl. Python 3 for the snippets. - Outbound port 25 open from your machine for step 4. Many cloud hosts and home ISPs block it.
- For step 5: a LeadOcean key in
$LEADOCEAN_API_KEY(the free plan has 1,000 records for life, no card) or the MCP server.
Step 1: Check the syntax
Syntax is the cheapest filter: it rejects typos before any network call. The format is a local part, an @, and a domain (see email address syntax).
Do not write a clever regex. Keep it permissive and let later steps decide.
import re
PATTERN = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]{2,}$")
def syntax_ok(addr: str) -> bool:
return len(addr) <= 254 and bool(PATTERN.match(addr))
for a in ["jane.doe@acme.com", "jane.doe@acme", "jane doe@acme.com", "@acme.com"]:
print(a, syntax_ok(a))Output:
jane.doe@acme.com True
jane.doe@acme False
jane doe@acme.com False
@acme.com FalseThe 254-character cap is the usual practical limit for a whole address. A pass here proves nothing about whether the mailbox exists.
Step 2: Filter disposable and role addresses
A valid-looking address can still be useless. Throwaway domains die within hours, and role mailboxes such as info@ or abuse@ are not people.
Keep a set of known disposable domains and a short list of role prefixes. Read disposable email address and temporary email address for what these domains are.
ROLE = {"info", "admin", "support", "sales", "abuse", "postmaster", "noreply", "no-reply"}
DISPOSABLE = {"mailinator.com", "guerrillamail.com", "10minutemail.com"} # load a maintained list
def classify(addr: str) -> str:
local, domain = addr.lower().split("@", 1)
if domain in DISPOSABLE:
return "disposable"
if local in ROLE:
return "role"
return "ok"
print(classify("jane.doe@acme.com"), classify("info@acme.com"), classify("x@mailinator.com"))Output:
ok role disposableThe three-domain set above is a stub. A real list has thousands of entries and changes weekly, so load one from a maintained source rather than hand-writing it.
Step 3: Look up the MX records
If the domain has no mail server, nothing you send can arrive. An MX lookup is a DNS query, so it is fast and costs nothing.
dig +short MX acme.comA domain that can receive mail returns one or more lines like this (placeholder values):
10 mx1.acme.com.
20 mx2.acme.com.An empty result means no MX. Per RFC 5321 (checked October 2026) a domain with no MX falls back to its address record, so also check dig +short A acme.com before you give up. A domain that returns nothing for both is dead.
import subprocess
def mx_hosts(domain: str) -> list[str]:
out = subprocess.run(["dig", "+short", "MX", domain], capture_output=True, text=True).stdout
rows = [l.split() for l in out.strip().splitlines() if l.strip()]
return [r[1].rstrip(".") for r in sorted(rows, key=lambda r: int(r[0]))]The list is sorted by preference, lowest number first. Step 4 uses the first host.
Step 4: Probe the mailbox over SMTP
The SMTP probe asks the mail server whether it would accept mail for the address, without sending a message. It is the only direct test of a single mailbox, and also the least reliable.
openssl s_client -starttls smtp -connect mx1.acme.com:25 -crlf -quiet <<'SMTP'
EHLO probe.example.org
MAIL FROM:<check@example.org>
RCPT TO:<jane.doe@acme.com>
QUIT
SMTPRead the reply to RCPT TO:
| Reply | Meaning |
|---|---|
| 250 | The server accepted the recipient. |
| 550 | The mailbox does not exist or is refused. |
| 450 or 451 | Temporary failure or greylisting. Retry later. |
| Connection timeout | Port 25 is blocked from your network. |
Three limits apply. A catch-all domain answers 250 for every address, including zzz123@acme.com, so a 250 proves nothing there. Large providers often rate-limit or mislead probes. And probing from a shared IP can get that IP blocklisted.
If you run the probe at volume, send from a dedicated host and keep the rate low. Most teams should not build this step, and should use step 5 instead.
Step 5: Read a stored status instead of probing
A stored status is a verdict someone already collected, so you skip steps 3 and 4 for every address in the database. LeadOcean publishes one email_status per person's best address: verified, catch_all_valid, catch_all, risky, unknown, untested, invalid, role, disposable, spam_trap, abuse, derived or none.
Size the list first. The count is free. From the MCP server, the leadocean_count_leads tool takes the same filters as search:
{
"country": ["US"],
"jobLevel": ["VP"],
"jobFunction": ["Sales & Business Development"],
"emailStatus": ["verified"],
"facets": false
}Then pull the rows over the REST API. The body is flat JSON and filters are arrays. limit runs from 1 to 100:
curl -s -X POST https://api.leadocean.io/v1/people/search \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"country": ["US"],
"jobLevel": ["VP"],
"jobFunction": ["Sales & Business Development"],
"emailStatus": ["verified"],
"limit": 25
}'To check one known person, enrich by linkedin_url, email, phone or person_id. Set reveal_email to get the address back:
curl -s -X POST 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 a search row>", "reveal_email": true}'An enrichment that finds nobody is a 404 and costs nothing. Everything else is 1 record per person returned.
Two rules from the API reference that matter here:
verifiedon a business address means LeadOcean's own verifier tested it. On a personal address it only means a supplier asserted it, so verify those yourself.- A search count of
verifiedis an upper bound. Enrichment applies the stricter per-address rule, and the enriched answer is the correct one.
What you get
Counts below come from leadocean_count_leads on 2026-10-21. The filter is country US, job level VP, job function Sales & Business Development, and only the email status shown.
| emailStatus filter | People | Distinct companies |
|---|---|---|
verified | 31,141 | 18,563 |
catch_all | 98,353 | 42,103 (estimate) |
Both totals are exact and both calls spent 0 records. As a filter, catch_all also matches catch_all_valid, so the second row includes those. The verified figure is an upper bound at search time. Notice how much larger the catch-all group is. Those are domains that accept any address, so you cannot confirm the mailbox by probing. This is why catch_all is a separate status rather than a pass.
Enrichment and the v2 contact endpoints return a small email object. This is the response shape from the API reference, with placeholder values:
{
"email": "jane.doe@acme.com",
"status": "verified",
"verified_batch_date": "2026-09-20"
}A verified_batch_date that is not null means LeadOcean's verifier tested the address in that run. A null means the status came from a source held to the same standard but not run by LeadOcean. A date is not a freshness timestamp, since a whole batch shares one.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing or wrong x-api-key header | Check $LEADOCEAN_API_KEY is exported and the key is active in the app. |
| 429 | Above the plan's rate limit (5 requests a second on Free, 200 on Agency) | Back off for the Retry-After value, then retry. |
| 402 | The 1,000 free records are spent | Check GET /v1/account, or move to a paid plan. |
| Empty results | A bad enum value, such as "VP Sales" for jobLevel | Use exact strings. jobLevel accepts C-Team, VP, Director, Manager, Staff, Other. List values with leadocean_list_enum_values. |
| Export refused | Over 50,000 rows in one request or 500,000 in a day | Split the job into several POST /v1/exports calls with narrower filters. Bulk CSV export is not on Free. |
| SMTP timeout (step 4) | Port 25 blocked by your network | Run the probe from a host that allows outbound 25, or skip to step 5. |
FAQ
Can you verify an email address without sending an email?
Yes. Steps 1 to 4 never send a message. Syntax, a domain check and an SMTP RCPT TO probe all stop before the DATA command, so nothing reaches the inbox.
Why does a catch-all domain defeat verification?
A catch-all server accepts mail for any local part, then sorts it later. The SMTP reply is 250 whether the mailbox exists or not. LeadOcean marks these catch_all, and catch_all_valid when it confirmed the mailbox through a Microsoft 365 or Google Workspace identity check.
Is a regex enough to verify an email?
No. A regex proves format only. jane.doe@acme.com passes whether or not Jane exists, so treat syntax as step 1 of 5.
What does a bounce cost me?
LeadOcean does not refund or credit back bounced emails, so read email_status before sending. Pick verified for cold outreach, and treat catch_all as a decision about risk.
How many records does verification use?
Sizing with leadocean_count_leads is free. Pulling people returns 1 record per person, and enriching someone it holds nothing for costs 0. More guides are on the blog.
Filter by email status before you send
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →