Email Verification API — Bulk SMTP, MX & Catch-All Check
Pricing
$2.00 / 1,000 email verifieds
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
Maintained by CommunityActor 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:
statusis always one of four values.deliverable,undeliverable,risky,unknown. Never null, never empty, never a fifth value.- Every field in
checksis always present. All 11 booleans, every time. A check that was never reached isfalse— the reason lives insub_status. No defensive null-checking in your code. - Catch-all and free providers are reported honestly. A catch-all domain returns
risky/catch_all, not a fake "valid". Gmail returnsrisky/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
checkskeys on every record, whether the SMTP layer ran or not. Nothing to branch on, nothing to guard. statushas exactly four values, forever. A caller can enumerate them once and be correct permanently. New information arrives insub_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
unknownorriskywith 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
| Field | Type | Default | Description |
|---|---|---|---|
emails | array of strings | [] | Addresses to verify. |
emailFileUrl | string | — | URL of a .txt (one per line) or .csv. Every email-shaped token in the file is picked up. Used in addition to emails. |
concurrency | integer 1–50 | 15 | Parallel SMTP handshakes. Addresses are grouped by domain first, so this is really "domains in flight". |
smtpTimeoutMs | integer 2000–20000 | 8000 | Per-reply SMTP timeout. |
verbose | boolean | false | Adds a raw block per record with per-layer detail (MX list, probe codes, raw SMTP text). Off by default to keep records small. |
heloName | string | angelnumbercodex.com | Hostname announced in EHLO. |
mailFrom | string | verify@angelnumbercodex.com | Envelope 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
| Field | Guarantee |
|---|---|
email | Your input, echoed verbatim — even when it fails syntax. |
normalized_email | Lower-cased; +tag stripped; dots folded for Gmail. Empty string if unparseable, never null. |
domain | Lower-cased domain, or empty string. Never null. |
status | Always one of 4 values. |
sub_status | Always one of the 12 codes below. |
score | Always an integer 0–100. |
checks | Always all 11 boolean keys. Never null inside. |
mx_record | Highest-priority MX host, or empty string. Never null. |
error | Present only on unknown/internal_error. |
raw | Present 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
status | sub_status | Meaning | Score |
|---|---|---|---|
deliverable | valid_mailbox | SMTP confirmed the mailbox exists and the domain is not catch-all. | 100 (90 on A-record fallback) |
undeliverable | invalid_syntax | Fails RFC 5322. | 0 |
undeliverable | no_mx_record | No MX and no A record (or RFC 7505 null MX). | 0 |
undeliverable | mailbox_not_found | SMTP 550 on the real address, on a non-catch-all domain. | 5 |
risky | catch_all | Domain accepts every address; no individual mailbox can be confirmed. | 50 |
risky | disposable | Domain is on the disposable/temp-mail blocklist. | 20 |
risky | role_based | Shared inbox (info@, admin@, support@, …) that SMTP confirmed exists. | 60 |
risky | full_mailbox | SMTP 452/552 — mailbox over quota. | 45 |
risky | free_provider_unverifiable | Gmail/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 |
unknown | greylisted | 421/450/451 — retry later. Not undeliverable. | 40 |
unknown | smtp_timeout | Host unreachable, timed out, rejected the session on policy/IP-reputation grounds, or the DNS resolver failed (SERVFAIL) instead of answering. | 30 |
unknown | internal_error | Unexpected 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
disposableis terminal — no SMTP is spent on a mailbox designed to expire.catch_allbeatsrole_based: on a catch-all domain nothing about the individual mailbox is knowable.role_basedbeatsdeliverable: a confirmedinfo@is a real mailbox but not a person.- A
5xxwhose text mentions blocklists, reputation, or policy is treated asunknown/smtp_timeout, notmailbox_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 isunknown/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, EHLO → MAIL FROM → RCPT TO. Reply codes only.
The catch-all probe is mandatory and runs first. Before asking about your address, the actor sends
RCPT TOfor a random address that cannot exist (zzq-nonexist-<random>@domain). A250there means the domain accepts everything, and every address on it is reportedrisky/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 availableto unknown senders from any IP we have tested, and reporting that asunknownwould tell you to retry something refused permanently.checks.smtp_connectablerecords it instead. - The mechanics. Gmail, Outlook, Yahoo and iCloud answer
250toRCPT TOfor addresses that do not exist, then bounce at delivery time. Any vendor claiming a per-mailbox Gmail verdict from SMTP is guessing. This actor returnsrisky/free_provider_unverifiableand setschecks.mailbox_exists: false. Syntax, domain and disposable checks still apply. - Catch-all domains cannot be mailbox-verified. By definition. You get
risky/catch_alland the honestcatch_all: trueflag. - 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. SetrelayUrlandrelayTokento 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 falseundeliverable. Watch theUNKNOWN RATEin 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/450returnsunknown/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 installnpm run buildnpm 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