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

How-to guide

How to Verify Emails in Real Time (Developer's Guide)

A three-layer check you can run in under a second: syntax, DNS, then a stored deliverability status. Real requests, real output, and one honest limit.

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

Real-time email verification means deciding whether an address is safe to accept or send to while the user waits, in well under a second. This guide builds that gate in three layers and shows where LeadOcean fits and where it does not.

TL;DR

  • Run cheap local checks first (syntax, MX record), then look up a stored deliverability status. About 30 minutes to build.
  • LeadOcean spends 0 records on counts and misses, and 1 record when an enrichment finds the person. It reads the email_status we already hold. It does not probe an arbitrary address live.
  • Gate on verified and catch_all_valid. Hold catch_all. Drop invalid, disposable, spam_trap and abuse.

Prerequisites

  • A LeadOcean API key in $LEADOCEAN_API_KEY (the free plan works: 1,000 records for life, 5 requests a second, no card).
  • Python 3.10+ with dnspython (pip install dnspython), or any language with an MX lookup.
  • curl and jq for the API steps.

Step 1: Reject bad syntax and dead domains locally

Do the free checks yourself before any network call to a vendor. A regex plus an MX lookup removes typos and fake domains in a few milliseconds.

python
import re
import dns.exception
import dns.resolver

EMAIL = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")

def local_check(address: str) -> str:
    if not EMAIL.match(address):
        return "bad_syntax"
    domain = address.rsplit("@", 1)[1].lower()
    try:
        dns.resolver.resolve(domain, "MX", lifetime=2.0)
    except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
        return "no_mx"
    except dns.exception.Timeout:
        return "dns_timeout"
    return "ok"

print(local_check("jane.doe@acme.com"))

Keep the syntax rule loose. RFC 5321 allows more than most regexes do, and a strict pattern rejects real users.

Step 2: Know what a live SMTP probe can and cannot tell you

A live probe opens a connection to the domain's mail server and issues RCPT TO without sending a message. The server's reply is the signal.

text
S: 220 mx.acme.com ESMTP
C: HELO verify.example.net
C: MAIL FROM:<check@example.net>
C: RCPT TO:<jane.doe@acme.com>
S: 250 OK            <- mailbox exists (or the domain accepts everything)
S: 550 No such user  <- mailbox does not exist

Two limits matter. A catch-all domain answers 250 for every address, so the probe proves nothing there. Many providers also throttle or block probes from unknown IPs. Running this from your app server on a signup form is slow and gets your IP flagged. A stored status avoids both.

Step 3: Read the stored status for a known person

If the address belongs to someone in the LeadOcean database, enrich by email and read email_status. Set reveal_email to see each address with its own status.

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 '{"email": "jane.doe@acme.com", "reveal_email": true}' \
  | jq '.data.contact_data | {email_status, email_type, contact_emails}'

A match costs 1 record. A person we hold nothing for is a 404 and costs nothing, so a miss is a free signal that we cannot vouch for the address. Treat a 404 as "unknown to us", not as "invalid".

Step 4: Gate on the status

Map each status to an action. This is the whole decision table.

python
ACCEPT = {"verified", "catch_all_valid"}
HOLD   = {"catch_all", "risky", "role", "derived", "unknown", "untested"}
REJECT = {"invalid", "disposable", "spam_trap", "abuse"}

def decide(status: str | None) -> str:
    if status in ACCEPT:
        return "accept"
    if status in REJECT:
        return "reject"
    return "hold"  # allow, but do not send cold mail until confirmed

untested means nobody has checked the address yet. unknown means a check ran and was inconclusive, which is a weaker signal than untested. On a personal address, verified still means a supplier asserted it, because we do not verify consumer domains.

Step 5: Size a whole list before you touch it

For batch work, count first. A count is free and tells you how many people on a list already carry a safe status.

bash
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"],"country":["US"],"emailStatus":["verified"],"emailType":["work"],"limit":1}' \
  | jq '.meta | {total, totalIsExact}'

meta.total is capped at 100,000. A search row can show verified and come back untested when enriched, so treat the search count as an upper bound and confirm per address on enrichment. The same count is available to an AI client through the leadocean_count_leads MCP tool.

What you get

A count run on 21 October 2026 through the leadocean_count_leads MCP tool, with jobLevel VP, country US, emailStatus verified and emailType work:

json
{
  "total": 220500,
  "totalIsExact": true,
  "distinctCompanies": 71979,
  "credits": 0
}

That is 220,500 US VPs with a verified work address, counted for free. It is filtered by emailStatus and emailType, so it is not a total of all US VPs.

A record-level enrichment returns this shape (response shape from the API reference, placeholder values):

json
{
  "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 date of the run that checked the address. A null there means the check was not ours, not that the address is unverified.

Troubleshooting

ErrorCauseFix
401Missing or wrong x-api-key headerSend the key as x-api-key: $LEADOCEAN_API_KEY, not as a bearer token
404 on enrichWe hold nothing for that personFree. Fall back to your own MX check and treat the address as unknown
429Above the plan's rate limit (5 a second on Free, 200 on Agency)Honor the Retry-After header and queue lookups
402Out of records on the Free planCount first, enrich only what you need, or move to a paid plan
Empty results from a status filterEnum value has the wrong case or spellingUse exact values such as verified and catch_all_valid; list them with leadocean_list_enum_values
Export stops shortExport ceiling: 50,000 rows a request, 500,000 a daySplit the job across requests and days

FAQ

Can LeadOcean verify any email address I send it?

No. It returns the stored status for people it holds. It does not open an SMTP connection to an address you supply. For an unknown address, use your own MX check and a hold rule.

How fresh is the status?

The dataset is refreshed monthly, and each address carries verified_batch_date when we ran the check. Do not treat a status as a live probe result.

What do I do with catch-all domains?

A catch-all domain accepts every address, so a probe cannot confirm a mailbox. catch_all_valid means we confirmed the mailbox through the provider's identity check, which covers Microsoft 365 managed tenants and Google Workspace. Treat plain catch_all as hold.

Does LeadOcean refund bounces?

No. There is no refund or credit-back for bounced emails, so read email_status before you send.

Where do I compare other verification tools?

See best email verification tools and email verification explained. For finder APIs, see best email finder tools for developers. Plans are on the pricing page, and more guides are in the blog.

Check your next list's email status for free

Free to start. No credit card. 1,000 records to spend whenever you like.

Get your free API key →