Email Verification API — Bulk SMTP, MX & Catch-All Check avatar

Email Verification API — Bulk SMTP, MX & Catch-All Check

Pricing

$2.00 / 1,000 email verifieds

Go to Apify Store
Email Verification API — Bulk SMTP, MX & Catch-All Check

Email Verification API — Bulk SMTP, MX & Catch-All Check

Bulk email verification with a flat, schema-stable output: syntax, MX/A, disposable/role/free lists, SMTP handshake and honest catch-all detection.

Pricing

$2.00 / 1,000 email verifieds

Rating

0.0

(0)

Developer

Sumitr Mardy

Sumitr Mardy

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

8 hours ago

Last modified

Share

Verify email addresses in bulk: RFC 5322 syntax → DNS/MX → disposable, role and free-provider lists → live SMTP handshake with a mandatory catch-all probe. One flat JSON record per address, built to be consumed by a machine.

No message is ever sent. The actor reads SMTP reply codes only — it never issues DATA.


Why this one

Most verifiers are easy to use until you put them in a pipeline. Then you find out that status is sometimes null, that checks.catch_all disappears when the check didn't run, and that "valid" quietly means "the domain accepted a random string".

This actor makes three promises and enforces them in tests:

  1. status is always one of four values. deliverable, undeliverable, risky, unknown. Never null, never empty, never a fifth value.
  2. Every field in checks is always present. All 11 booleans, every time. A check that was never reached is false — the reason lives in sub_status. No defensive null-checking in your code.
  3. Catch-all and free providers are reported honestly. A catch-all domain returns risky/catch_all, not a fake "valid". Gmail returns risky/free_provider_unverifiable, not a fabricated mailbox verdict. See Known limits.

Built to be called by a machine

Most verifiers are written for a human reading a dashboard. This one assumes the caller is code — a pipeline step, a workflow node, or an AI agent holding it as a tool.

That distinction is not marketing. It changes what the output has to guarantee.

A human sees catch_all missing from a response and shrugs. An agent reasoning over the same response concludes the domain is not catch-all, marks the address safe, and sends. An agent cannot write result.checks?.catch_all ?? false — it acts on what it reads. So a field that sometimes disappears is not a minor inconvenience; it is a wrong answer with no warning attached.

What that means here:

  • The response shape never varies. Same 11 checks keys on every record, whether the SMTP layer ran or not. Nothing to branch on, nothing to guard.
  • status has exactly four values, forever. A caller can enumerate them once and be correct permanently. New information arrives in sub_status, which is also always populated.
  • Verdicts are deterministic. Scores come from a lookup table keyed on the verdict — no model, no randomness. The same address with the same verdict scores the same today and in a year, which is what makes a result cacheable and a test repeatable.
  • Uncertainty is a value, not a gap. When the mailbox genuinely cannot be determined, you get unknown or risky with a reason — never a confident guess and never a null. An agent that can distinguish "this is dead" from "I could not find out" makes better decisions than one handed a boolean.

Being a standard Apify Actor, it is callable from anything that speaks the Apify API — including agent toolchains and MCP integrations — and the input schema doubles as the tool's parameter description.


Input

FieldTypeDefaultDescription
emailsarray of strings[]Addresses to verify.
emailFileUrlstringURL of a .txt (one per line) or .csv. Every email-shaped token in the file is picked up. Used in addition to emails.
concurrencyinteger 1–5015Parallel SMTP handshakes. Addresses are grouped by domain first, so this is really "domains in flight".
smtpTimeoutMsinteger 2000–200008000Per-reply SMTP timeout.
verbosebooleanfalseAdds a raw block per record with per-layer detail (MX list, probe codes, raw SMTP text). Off by default to keep records small.
heloNamestringangelnumbercodex.comHostname announced in EHLO.
mailFromstringverify@angelnumbercodex.comEnvelope sender used in MAIL FROM.

The defaults already point at a domain with clean SPF, DKIM and DMARC, and the published accuracy numbers were measured with them. Override them only if you want the handshake to identify itself as your own domain. It is the single biggest accuracy lever on business domains: many MX servers downgrade or reject a session whose HELO name has no DNS, or whose envelope sender domain doesn't resolve. The defaults work, but a real domain works better.

At least one of emails / emailFileUrl must yield an address, otherwise the run fails immediately with a clear message.

{
"emails": [
"john@example.com",
"info@apify.com",
"someone@gmail.com",
"test@mailinator.com"
],
"concurrency": 15,
"smtpTimeoutMs": 8000,
"verbose": false
}

Output

One record per input address, pushed to the dataset:

{
"email": "john@example.com",
"normalized_email": "john@example.com",
"domain": "example.com",
"status": "deliverable",
"sub_status": "valid_mailbox",
"score": 100,
"checks": {
"syntax": true,
"domain_exists": true,
"mx_found": true,
"a_record_fallback": false,
"smtp_connectable": true,
"mailbox_exists": true,
"catch_all": false,
"disposable": false,
"role_based": false,
"free_provider": false,
"has_tag": false
},
"mx_record": "aspmx.l.google.com",
"verified_at": "2026-08-14T10:00:00.000Z",
"duration_ms": 340
}

Field guarantees

FieldGuarantee
emailYour input, echoed verbatim — even when it fails syntax.
normalized_emailLower-cased; +tag stripped; dots folded for Gmail. Empty string if unparseable, never null.
domainLower-cased domain, or empty string. Never null.
statusAlways one of 4 values.
sub_statusAlways one of the 12 codes below.
scoreAlways an integer 0100.
checksAlways all 11 boolean keys. Never null inside.
mx_recordHighest-priority MX host, or empty string. Never null.
errorPresent only on unknown/internal_error.
rawPresent only when verbose: true.

An unexpected failure on one address produces unknown / internal_error with an error string. It never aborts the run or drops a record.


Status × sub_status — the full table

statussub_statusMeaningScore
deliverablevalid_mailboxSMTP confirmed the mailbox exists and the domain is not catch-all.100 (90 on A-record fallback)
undeliverableinvalid_syntaxFails RFC 5322.0
undeliverableno_mx_recordNo MX and no A record (or RFC 7505 null MX).0
undeliverablemailbox_not_foundSMTP 550 on the real address, on a non-catch-all domain.5
riskycatch_allDomain accepts every address; no individual mailbox can be confirmed.50
riskydisposableDomain is on the disposable/temp-mail blocklist.20
riskyrole_basedShared inbox (info@, admin@, support@, …) that SMTP confirmed exists.60
riskyfull_mailboxSMTP 452/552 — mailbox over quota.45
riskyfree_provider_unverifiableGmail/Outlook/Yahoo/iCloud etc. Their RCPT answers carry no information, so no mailbox verdict is claimed. Decided by the domain, not by whether the SMTP session succeeded.55
unknowngreylisted421/450/451 — retry later. Not undeliverable.40
unknownsmtp_timeoutHost unreachable, timed out, rejected the session on policy/IP-reputation grounds, or the DNS resolver failed (SERVFAIL) instead of answering.30
unknowninternal_errorUnexpected processing error; error field explains.0

Scores are a pure function of the verdict — no randomness, no model. The same address with the same verdict scores the same forever.

Precedence, so nothing surprises you

  • disposable is terminal — no SMTP is spent on a mailbox designed to expire.
  • catch_all beats role_based: on a catch-all domain nothing about the individual mailbox is knowable.
  • role_based beats deliverable: a confirmed info@ is a real mailbox but not a person.
  • A 5xx whose text mentions blocklists, reputation, or policy is treated as unknown/smtp_timeout, not mailbox_not_found. Rejections aimed at the sender are never turned into claims about the recipient.
  • The same rule applies one layer down: an authoritative NXDOMAIN is undeliverable/no_mx_record, but a resolver SERVFAIL or timeout is unknown/smtp_timeout. "We could not find out" never becomes "it does not exist".

How verification works

Layer 1 — Syntax. RFC 5322 via validator.js. Invalid → terminal.

Layer 2 — DNS. MX lookup, sorted by preference. No MX but an A record → RFC 5321 implicit MX, flagged as a_record_fallback. Neither → no_mx_record, unless the resolver failed rather than answered, which is unknown.

Layer 3 — Lists. Disposable blocklist (refreshed daily from the public disposable-email-domains list, ~8k domains, with a bundled fallback), role-account local parts, free-provider domains, +tag detection.

Layer 4 — SMTP. Connect to the MX on port 25, EHLOMAIL FROMRCPT TO. Reply codes only.

The catch-all probe is mandatory and runs first. Before asking about your address, the actor sends RCPT TO for a random address that cannot exist (zzq-nonexist-<random>@domain). A 250 there means the domain accepts everything, and every address on it is reported risky/catch_all — the real address is never even probed, because the answer would be meaningless.


Known limits

Stated openly, because you would find them anyway:

  • Free providers cannot be mailbox-verified. This verdict comes from the domain, so it holds even when the provider refuses the session outright — GMX and web.de answer 554 Nemesis ESMTP Service not available to unknown senders from any IP we have tested, and reporting that as unknown would tell you to retry something refused permanently. checks.smtp_connectable records it instead.
  • The mechanics. Gmail, Outlook, Yahoo and iCloud answer 250 to RCPT TO for addresses that do not exist, then bounce at delivery time. Any vendor claiming a per-mailbox Gmail verdict from SMTP is guessing. This actor returns risky/free_provider_unverifiable and sets checks.mailbox_exists: false. Syntax, domain and disposable checks still apply.
  • Catch-all domains cannot be mailbox-verified. By definition. You get risky/catch_all and the honest catch_all: true flag.
  • Outbound port 25 must be open, and on Apify it is not. Measured, not assumed: every direct connection to port 25 from an Actor run is silently dropped, which turns every SMTP-dependent address into unknown/smtp_timeout. Set relayUrl and relayToken to point at a relay running somewhere port 25 works — see relay/README.md. Without one you get the syntax, DNS and list layers only.
  • Egress IP reputation decides the unknown rate. Some MX servers throttle or refuse unknown senders — those become unknown/smtp_timeout, never a false undeliverable. Watch the UNKNOWN RATE in the run log; a high value means the SMTP path is being blocked, not that your list is bad.
  • Greylisting is not a verdict. 421/450 returns unknown/greylisted. Re-run those addresses later; the MX and catch-all caches make the retry cheap.

Performance & cost

  • MX cache, keyed by domain, 24h TTL, persisted in the key-value store — so it survives across runs. A 5,000-address list is usually a few hundred domains.
  • Catch-all cache, same key and TTL. Each domain is probed once, not once per address.
  • Domain grouping + session reuse. Addresses are grouped by domain; one SMTP session serves many RCPT TOs, reconnecting on a budget when a server caps the session.
  • Results stream to the dataset in batches while the run is still going.

Every run logs its own KPIs: throughput, the full status/sub_status distribution, cache hit rates, and the unknown rate.


Pricing

Pay per event — email_verified, $0.002 per address.

One charge per address that produces a result, including undeliverable and unknown: the compute was spent either way, and a confirmed bad address is exactly the result you were paying to get. No per-run charge.


Usage

Via the Apify API (Node):

$npm install apify-client
import { ApifyClient } from 'apify-client';
const client = new ApifyClient({ token: 'YOUR_TOKEN' });
const run = await client.actor('YOUR_USERNAME/email-verifier').call({
emails: ['john@example.com', 'info@apify.com'],
concurrency: 15,
});
const { items } = await client.dataset(run.defaultDatasetId).listItems();
for (const r of items) {
console.log(r.email, r.status, r.sub_status, r.score);
}

Filtering, without a single null check:

const mailable = items.filter((r) => r.status === 'deliverable');
const worthRetrying = items.filter((r) => r.status === 'unknown');
const neverSend = items.filter((r) => r.status === 'undeliverable' || r.checks.disposable);

Development

npm install
npm run build
npm test

npm test runs the contract tests — no network required. They assert the field guarantees above, the SMTP code mapping, and score determinism.

The accuracy gate makes real DNS and SMTP calls against a labelled set:

$npm run accuracy -- test/fixtures/labeled.csv

It prints per-group accuracy and the unknown rate, and exits non-zero below the 95% target. The own-domain-valid, own-domain-invalid and catch-all groups need a throwaway side-project domain you control — those are the branches that carry the product. Do not bulk-probe your main company domain.

Latest measured run: 100% sub_status match over 166 labelled rows, 1.8% unknown — scored identically running direct and running through the relay, which is how the transport was shown to be equivalent.

To score a run that happened elsewhere — an Apify run, whose IP is the one that actually matters — export its dataset as JSON and pass it in. Nothing is verified locally; the exported records are scored against the same labels:

$npm run accuracy -- --dataset path/to/dataset.json