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

How-to guide

How to Verify an Email Address (5 Methods With Real Commands)

Five checks, from free local tests to a stored deliverability status, with the commands and queries to run each one.

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

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 (or nslookup) and openssl. 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.

python
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:

code
jane.doe@acme.com True
jane.doe@acme False
jane doe@acme.com False
@acme.com False

The 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.

python
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:

code
ok role disposable

The 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.

bash
dig +short MX acme.com

A domain that can receive mail returns one or more lines like this (placeholder values):

code
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.

python
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.

bash
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
SMTP

Read the reply to RCPT TO:

ReplyMeaning
250The server accepted the recipient.
550The mailbox does not exist or is refused.
450 or 451Temporary failure or greylisting. Retry later.
Connection timeoutPort 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:

json
{
  "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:

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 '{
    "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:

bash
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:

  • verified on 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 verified is 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 filterPeopleDistinct companies
verified31,14118,563
catch_all98,35342,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:

json
{
  "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

ErrorCauseFix
401Missing or wrong x-api-key headerCheck $LEADOCEAN_API_KEY is exported and the key is active in the app.
429Above the plan's rate limit (5 requests a second on Free, 200 on Agency)Back off for the Retry-After value, then retry.
402The 1,000 free records are spentCheck GET /v1/account, or move to a paid plan.
Empty resultsA bad enum value, such as "VP Sales" for jobLevelUse exact strings. jobLevel accepts C-Team, VP, Director, Manager, Staff, Other. List values with leadocean_list_enum_values.
Export refusedOver 50,000 rows in one request or 500,000 in a daySplit 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 networkRun 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 →