To verify an email address, run three checks in order: the syntax, the domain's MX record, and the mailbox itself. The first two take seconds and catch typos and dead domains. The third is the one that decides bounces, and it fails on catch-all domains. This guide runs each check with real commands, then shows the shortcut of reading a stored verdict.
More guides are on the blog. Related: the verifier test on 5,000 addresses, the short version of this guide and the sender setup for Instantly, Smartlead and lemlist.
TL;DR
- Verification is three checks: syntax, MX record, then a mailbox probe. Only the mailbox probe tells you the address exists.
- Steps 1 to 3 run on your own machine in about 15 minutes and spend no LeadOcean records.
- Reading a stored verdict through LeadOcean costs 1 record per enriched address. Counting a list by status is free.
Prerequisites
- A shell with
digand Python 3. - A network that allows outbound port 25. Many home and cloud networks block it, so step 3 may need another host.
- For steps 4 and 5, a free LeadOcean key exported as
$LEADOCEAN_API_KEY. The free plan has 1,000 records for life and no card. - Placeholder addresses only. Swap in your own. Use
jane.doe@acme.comstyle values in tests, not real people.
Clay's own verification guide describes the same three-check model. It explains the concepts. This page is the runnable version.
Step 1: Check the syntax
A syntax check rejects addresses that cannot be valid before you spend a network call. Keep it loose. A strict regex rejects real addresses, so test for one @, a non-empty local part and a dotted domain.
import re
def syntax_ok(addr: str) -> bool:
return bool(re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", addr.strip()))
for a in ["jane.doe@acme.com", "jane.doe@@acme..com", "jane doe@acme.com"]:
print(a, syntax_ok(a))The first address passes. The second and third fail. Passing proves nothing about the mailbox. It only means the string is shaped like an address.
Step 2: Look up the MX record
An MX lookup tells you whether the domain accepts mail at all. If it returns no records, every address on that domain bounces.
dig +short MX example.com0 .That is real output, run on 21 October 2026. The 0 . answer is a null MX, which a domain publishes to say it does not accept mail. A healthy domain returns lines like 10 mx1.acme.com. with a priority and a mail host. Take the lowest priority number and use that host in step 3.
Step 3: Probe the mailbox over SMTP
The mailbox probe opens a connection to the mail host, says who you are, and asks whether the recipient exists. You stop before sending any message. The server answers 250 for yes, a 5xx code for no, and sometimes a 4xx code for "try later".
import smtplib
def probe(mx_host: str, addr: str, helo="verify.example.net", sender="probe@example.net"):
s = smtplib.SMTP(mx_host, 25, timeout=15)
s.ehlo(helo)
s.mail(sender)
code, msg = s.rcpt(addr)
s.quit()
return code, msg.decode(errors="replace")
print(probe("mx1.acme.com", "jane.doe@acme.com"))Read the result by code. 250 means the server accepted the recipient. 550 means the mailbox does not exist. 450 and 451 usually mean greylisting or a rate limit, so retry later and do not call the address bad.
Test each address once and cache the answer. Run this too often from one IP and receiving servers start refusing you. That is the practical reason teams buy verification instead of running it.
The catch-all problem
Some domains accept every recipient and sort the mail afterwards. Probe a random address on the same domain first. If it is also accepted, the domain is a catch-all and a 250 for your target means nothing.
import uuid
fake = f"{uuid.uuid4().hex[:12]}@acme.com"
code, _ = probe("mx1.acme.com", fake)
print("catch-all" if code == 250 else "normal domain")On a catch-all domain you cannot confirm the mailbox from SMTP alone. You can only label the address catch_all and decide whether to take the risk.
Step 4: Read the stored verdict instead
Doing steps 1 to 3 for 10,000 addresses means managing IP reputation, retries, greylisting and catch-all detection yourself. For a list, read a verdict that was already recorded. The trade is that you read a result someone else produced, so check its date. LeadOcean stores an email_status for each person's best address, and enrichment by email returns it.
curl -s -X POST https://api.leadocean.io/v1/people/enrich \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"email": "jane.doe@acme.com", "reveal_email": true}'This costs 1 record. A person with nothing on file returns a 404 and costs nothing. Enrich takes exactly one of linkedin_url, email, phone or person_id.
Step 5: Count and filter a list by status
Verify at the point of sourcing, not at the point of sending. Filtering by status when you pull the list means unsendable addresses never reach your sequencer.
Counting a list by email status is free, so size the list before you spend anything. Send count=true as a query parameter with limit set to 1. The count call returns meta.total and an empty data array.
curl -s -X POST "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"],
"limit": 1
}'Filter values are exact strings. Look them up with GET /v1/enums/email_status or the free leadocean_list_enum_values MCP tool rather than guessing. Drop count=true and raise limit (1 to 100) to pull rows, then page with cursor.
What you get
The status values come from leadocean_list_enum_values, run on 21 October 2026. There are 13.
| Status | What it means | Action |
|---|---|---|
verified | Our verifier sent to the address and it accepted | Send |
catch_all_valid | Catch-all domain, mailbox confirmed (Microsoft 365 and Google Workspace only) | Send |
catch_all | Domain accepts everything, mailbox unconfirmed | Your call |
risky, role, derived | Use with care: shared mailbox or pattern guess | Hold back |
untested | Nobody has checked it yet | Verify first |
unknown | Checked, result inconclusive | Verify first |
invalid, spam_trap, abuse, disposable | Do not send | Drop |
none | No email on file | Skip |
The count from step 5, run with the free leadocean_count_leads tool on 21 October 2026.
{
"filters": "jobLevel VP, jobFunction Sales & Business Development, country CA, emailStatus verified",
"total": 1854,
"totalIsExact": true,
"distinctCompanies": 1392,
"credits": 0
}That is a count of people whose best email is marked verified, not a mailable count. A search count of verified is an upper bound. Enrichment applies the narrower per-address rule, so a person can show verified in search and untested once enriched. Trust the enriched answer.
This is the response shape from the API reference for step 4, with 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",
"verified_batch_date": "2026-09-20"
}
]
}
}
}verified_batch_date is the verification run, not a timestamp for this address. A null date means the check was not run by LeadOcean. It does not mean the address is unverified.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing or wrong x-api-key | Re-export $LEADOCEAN_API_KEY and resend the header |
| 429 | Over the plan's rate limit (5 requests a second on Free, 200 on Agency) | Back off and retry after the Retry-After header |
| 402 | The plan is out of records | Check GET /v1/account, or move to a paid plan on the pricing page |
| Count is 0 or filters rejected | An enum value is not an exact string | Resolve it with GET /v1/enums/email_status |
| 404 on enrich | Nobody on file for that identifier | Nothing was charged. Try another identifier |
400 on POST /v1/exports | Export over 50,000 rows a request, or 500,000 a day | Split the export into smaller filtered runs |
FAQ
Can I verify an email without sending a message?
Yes. The SMTP probe in step 3 asks the receiving server about the recipient and quits before any message is sent. Some servers refuse the probe or accept everything, so treat the result as evidence, not proof.
Why does a verified address still bounce?
Verification is a snapshot. People leave companies and servers change. Check the verified_batch_date and re-verify addresses you have not used for a few months. LeadOcean does not refund or credit back bounces, so read email_status before you send.
How do I handle catch-all domains?
Label them. A catch-all 250 cannot confirm the mailbox. catch_all_valid is the exception: LeadOcean confirms those mailboxes through the provider's identity check, for Microsoft 365 managed tenants and Google Workspace only. Send those, and send the rest in small batches.
Is syntax validation the same as verification?
No. Validation checks the shape of the string. Verification asks the mail server whether the mailbox exists. An address can pass the first and fail the second.
What does it cost?
Counts and enum lookups are free. One enrichment is 1 record. The Free plan has 1,000 records for life, Solo is $97 a month and Agency is $497 a month. See pricing.
Check your list before you send it
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →