Check for a business email in JavaScript, Python and PHP
Check for a business email in JavaScript, Python and PHP: a free-provider list plus MX lookup you can run today, its pitfalls, and calling an API safely.
To check for a business email in code, take the domain after the @, compare it with a list of free email providers, then look up the domain’s MX records to confirm it can receive mail. A custom domain that isn’t on the list and has working MX is very likely a work address. That baseline fits in a few dozen lines of JavaScript, Python or PHP, and all three are below. The rest of this post covers where it falls short and how to call an API from your server without letting it slow down or break your signups.
How the do-it-yourself check works
The question your code answers isn’t “is this a big company?” It’s “is this domain shared by unrelated people, or does it belong to one organization?” What is a business email? covers the definition, and how to tell if an email is a business email the manual methods. In code, it comes down to three steps:
- Extract and normalize the domain. Trim the input, take everything after the last
@, lowercase it, drop a trailing dot and convert internationalized domains to punycode.Jane@Acme.IO.andjane@acme.iomust give the same answer. - Compare it with a free-provider list. If the domain is
gmail.com,outlook.com,gmx.deor another shared provider, the address is personal. - Look up its MX records. A domain that doesn’t exist, or publishes a null MX, can’t receive mail. An MX host that only serves consumer mailboxes, such as
gmail-smtp-in.l.google.com, means personal even when the domain is missing from your list. Any other MX means a custom domain that receives mail: business.
Your function should return four outcomes, not two:
| Outcome | When | What to do |
|---|---|---|
business |
Custom domain with working MX | Allow |
personal |
On the free-provider list, or consumer-only MX | Ask for a work email |
invalid |
Domain doesn’t exist, or publishes a null MX | Ask the user to fix the address |
unknown |
No MX records, or the DNS lookup failed | Allow, flag for review, re-check later |
The unknown row is the one people skip. A DNS timeout says nothing about the address, so it must never turn into a rejection. And a domain without MX records can still receive mail, because SMTP falls back to the domain’s address record (the “implicit MX” rule in RFC 5321). A null MX (MX 0 ., defined in RFC 7505) is different: the domain says outright that it accepts no mail.
The examples below expect our free email providers list saved next to the script, one domain per line:
curl -sO https://isbusinessemail.com/lists/free-email-providers.txt
JavaScript (Node.js): list plus MX lookup
Node has DNS built in. The Resolver class from node:dns/promises takes a timeout and a retry count, so a slow nameserver can’t hang your request. Save this as business-email.mjs:
// business-email.mjs -- Node 18+, no dependencies
import { readFileSync } from 'node:fs';
import { Resolver } from 'node:dns/promises';
import { domainToASCII } from 'node:url';
// One domain per line: https://isbusinessemail.com/lists/free-email-providers.txt
const FREE = new Set(
readFileSync(new URL('./free-email-providers.txt', import.meta.url), 'utf8')
.split('\n')
.map((line) => line.trim().toLowerCase())
.filter(Boolean),
);
// MX hosts that only serve consumer mailboxes. Catches provider aliases your list misses.
const CONSUMER_MX = ['gmail-smtp-in.l.google.com', 'olc.protection.outlook.com', 'yahoodns.net'];
const isConsumerMx = (host) => CONSUMER_MX.some((s) => host === s || host.endsWith(`.${s}`));
const resolver = new Resolver({ timeout: 1500, tries: 2 }); // timeout is per try, in ms
function domainOf(email) {
const at = email.lastIndexOf('@');
if (at < 1) return null;
// Lowercases, converts IDN to punycode, and returns '' for an invalid domain.
const domain = domainToASCII(email.slice(at + 1).trim().replace(/\.$/, ''));
return domain.includes('.') ? domain : null;
}
export async function classifyEmail(input) {
const domain = domainOf(String(input).trim());
if (!domain) return { verdict: 'invalid', reason: 'bad_syntax' };
if (FREE.has(domain)) return { verdict: 'personal', domain, reason: 'free_provider_list' };
let records;
try {
records = await resolver.resolveMx(domain);
} catch (err) {
if (err.code === 'ENOTFOUND') return { verdict: 'invalid', domain, reason: 'no_such_domain' };
// No MX records: mail may still go to the domain's A record ("implicit MX").
if (err.code === 'ENODATA') return { verdict: 'unknown', domain, reason: 'no_mx' };
return { verdict: 'unknown', domain, reason: `dns_${err.code}` }; // timeout, SERVFAIL: no verdict
}
const hosts = records
.sort((a, b) => a.priority - b.priority)
.map((r) => r.exchange.toLowerCase().replace(/\.$/, ''));
if (hosts.every((h) => h === '')) return { verdict: 'invalid', domain, reason: 'null_mx' };
if (hosts.some(isConsumerMx)) return { verdict: 'personal', domain, reason: 'consumer_mx' };
return { verdict: 'business', domain, reason: 'custom_domain_mx', mx: hosts[0] };
}
// Try it: node business-email.mjs jane@acme.io
if (process.argv[1]?.endsWith('business-email.mjs')) {
console.log(await classifyEmail(process.argv[2] ?? ''));
}
Node reports a domain that doesn’t exist as ENOTFOUND and “the domain exists but has no MX records” as ENODATA. Everything else, such as ETIMEOUT or ESERVFAIL, is a failed lookup, not an answer about the address.
Python: list plus MX lookup
Python’s standard library can’t query MX records, so this version uses the dnspython package (pip install dnspython):
# business_email.py -- Python 3.10+, pip install dnspython
import sys
from pathlib import Path
import dns.exception
import dns.resolver
# One domain per line: https://isbusinessemail.com/lists/free-email-providers.txt
LIST = Path(__file__).with_name("free-email-providers.txt")
FREE = {line.strip().lower() for line in LIST.read_text(encoding="utf-8").splitlines() if line.strip()}
# MX hosts that only serve consumer mailboxes. Catches provider aliases your list misses.
CONSUMER_MX = ("gmail-smtp-in.l.google.com", "olc.protection.outlook.com", "yahoodns.net")
resolver = dns.resolver.Resolver()
resolver.lifetime = 3.0 # seconds for the whole lookup, retries included
def is_consumer_mx(host: str) -> bool:
return any(host == s or host.endswith("." + s) for s in CONSUMER_MX)
def domain_of(email: str) -> str | None:
local, at, domain = email.strip().rpartition("@")
if not at or not local:
return None
try:
domain = domain.strip().rstrip(".").lower().encode("idna").decode("ascii")
except UnicodeError:
return None
return domain if "." in domain else None
def classify_email(email: str) -> dict:
domain = domain_of(email)
if not domain:
return {"verdict": "invalid", "reason": "bad_syntax"}
if domain in FREE:
return {"verdict": "personal", "domain": domain, "reason": "free_provider_list"}
try:
answer = resolver.resolve(domain, "MX")
except dns.resolver.NXDOMAIN:
return {"verdict": "invalid", "domain": domain, "reason": "no_such_domain"}
except dns.resolver.NoAnswer:
# No MX records: mail may still go to the domain's A record ("implicit MX").
return {"verdict": "unknown", "domain": domain, "reason": "no_mx"}
except dns.exception.DNSException as exc: # timeout, SERVFAIL: no verdict
return {"verdict": "unknown", "domain": domain, "reason": f"dns_{type(exc).__name__}"}
records = sorted(answer, key=lambda r: r.preference)
hosts = [str(r.exchange).rstrip(".").lower() for r in records]
if all(h == "" for h in hosts):
return {"verdict": "invalid", "domain": domain, "reason": "null_mx"}
if any(is_consumer_mx(h) for h in hosts):
return {"verdict": "personal", "domain": domain, "reason": "consumer_mx"}
return {"verdict": "business", "domain": domain, "reason": "custom_domain_mx", "mx": hosts[0]}
if __name__ == "__main__":
print(classify_email(sys.argv[1] if len(sys.argv) > 1 else "")) # python business_email.py jane@acme.io
dnspython raises a different exception for each case: NXDOMAIN when the domain doesn’t exist, NoAnswer when it exists without MX records, and a timeout or NoNameservers when the lookup itself failed. Order matters in the except chain, because all of them inherit from DNSException.
PHP: list plus MX lookup
PHP has two built-in options. getmxrr() is the shortest, but it returns false for “no such domain”, “no MX records” and “DNS error” alike, so you can’t tell an invalid address from a resolver hiccup. dns_get_record() returns false (with a warning) when the lookup fails and an empty array when there’s simply nothing there, which is what this version relies on:
<?php
// business_email.php -- PHP 8+; the intl extension is optional (for IDN domains)
declare(strict_types=1);
// One domain per line: https://isbusinessemail.com/lists/free-email-providers.txt
$lines = file(__DIR__ . '/free-email-providers.txt', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$FREE = array_flip(array_map(fn (string $l): string => strtolower(trim($l)), $lines));
// MX hosts that only serve consumer mailboxes. Catches provider aliases your list misses.
const CONSUMER_MX = ['gmail-smtp-in.l.google.com', 'olc.protection.outlook.com', 'yahoodns.net'];
function is_consumer_mx(string $host): bool
{
foreach (CONSUMER_MX as $s) {
if ($host === $s || str_ends_with($host, '.' . $s)) {
return true;
}
}
return false;
}
function domain_of(string $email): ?string
{
$at = strrpos($email, '@');
if ($at === false || $at === 0) {
return null;
}
$domain = rtrim(strtolower(trim(substr($email, $at + 1))), '.');
if (function_exists('idn_to_ascii')) {
$domain = idn_to_ascii($domain, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46) ?: '';
}
return str_contains($domain, '.') ? $domain : null;
}
function classify_email(string $email, array $free): array
{
$domain = domain_of(trim($email));
if ($domain === null) {
return ['verdict' => 'invalid', 'reason' => 'bad_syntax'];
}
if (isset($free[$domain])) {
return ['verdict' => 'personal', 'domain' => $domain, 'reason' => 'free_provider_list'];
}
// false = the lookup failed (timeout, SERVFAIL); [] = no MX records, or no such domain.
$records = @dns_get_record($domain, DNS_MX);
if ($records === false) {
return ['verdict' => 'unknown', 'domain' => $domain, 'reason' => 'dns_error'];
}
if ($records === []) {
// Mail can still go to an A/AAAA record ("implicit MX"); with neither, nothing receives mail.
$hasAddress = checkdnsrr($domain, 'A') || checkdnsrr($domain, 'AAAA');
return $hasAddress
? ['verdict' => 'unknown', 'domain' => $domain, 'reason' => 'no_mx']
: ['verdict' => 'invalid', 'domain' => $domain, 'reason' => 'no_mail_host'];
}
usort($records, fn (array $a, array $b): int => $a['pri'] <=> $b['pri']);
$hosts = array_map(fn (array $r): string => rtrim(strtolower($r['target']), '.'), $records);
if (array_filter($hosts) === []) {
return ['verdict' => 'invalid', 'domain' => $domain, 'reason' => 'null_mx'];
}
foreach ($hosts as $host) {
if (is_consumer_mx($host)) {
return ['verdict' => 'personal', 'domain' => $domain, 'reason' => 'consumer_mx'];
}
}
return ['verdict' => 'business', 'domain' => $domain, 'reason' => 'custom_domain_mx', 'mx' => $hosts[0]];
}
// Try it: php business_email.php jane@acme.io
if (PHP_SAPI === 'cli' && realpath($argv[0]) === __FILE__) {
print_r(classify_email($argv[1] ?? '', $FREE));
}
Neither PHP function takes a timeout. Both follow the system resolver’s settings (on Linux, /etc/resolv.conf), so on a busy signup path, cache results by domain or move the lookup into a background job.
Where the do-it-yourself check falls short
The baseline gets the common cases right: jane@gmail.com is personal, and jane@acme.io on Google Workspace MX is business. The rest is where the work is:
| Gap | What goes wrong | What fixing it takes |
|---|---|---|
| Stale lists | New providers, regional webmail and ISP domains pass as business | Refreshing the list on a schedule; ours changes daily |
| Disposable inboxes | Most have normal-looking MX, so they pass as business | A disposable domains list, plus spotting MX hosts shared with known throwaway domains |
| Privacy relays | privaterelay.appleid.com, mozmail.com and duck.com look like custom domains |
A relay list (email relay addresses explains them) |
| Typos | gmial.com may not exist, or may be registered by someone else and accept mail |
Suggestions based on edit distance to popular providers |
| Subdomains | mail.acme.co.uk and acme.co.uk need the same answer |
The Public Suffix List, to find the registrable domain |
| Forwarding and gateways | A forwarding service in MX hides where mail really lands; a security gateway such as Proofpoint or Mimecast hides Google Workspace or Microsoft 365 | MX fingerprints, plus DKIM, SPF and Microsoft tenant discovery |
| Speed | DNS is sometimes slow, and your signup waits for it | Timeouts, caching by domain, background re-checks |
One thing not to add: SMTP RCPT TO probes that ask the mail server whether jane@ exists. They’re unreliable on catch-all domains and amount to user enumeration. For more on reading MX hosts, see Detecting business emails with MX records, or look up any domain with the MX lookup tool.
Calling an API instead
An API such as isBusinessEmail runs the lists, DNS checks, typo detection and Google Workspace / Microsoft 365 detection, and returns one JSON answer. A free secret key comes with sign-up. Until you have one, the reserved test addresses, such as workspace@test.isbusinessemail.com, work without a key and never count toward a quota.
The fields most code needs:
recommendation:allow,revieworblockunder your policy (b2bby default;strictandlenientare the others).category:business,personal,disposable,relay,education,government,invalidorunknown.did_you_mean: a suggested fix for typos such asgmial.com. Show it to the user; don’t apply it silently.workspace.google_workspace.detectedandworkspace.microsoft_365.detected: which suite the company runs.
Every field is described in Response fields, and every option in Check endpoint.
curl
curl -s https://api.isbusinessemail.com/v1/check \
--max-time 3 \
-H "Authorization: Bearer $IBE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"email":"jane@acme.io"}'
A trimmed response:
{
"email": "jane@acme.io",
"domain": "acme.io",
"is_business": true,
"category": "business",
"recommendation": "allow",
"is_free_provider": false,
"is_disposable": false,
"is_relay": false,
"did_you_mean": null,
"mail": { "has_mx": true, "provider": "google_workspace", "spf": true, "dmarc": "reject" },
"workspace": {
"google_workspace": { "detected": true, "evidence": ["mx_google", "dkim_google"] },
"microsoft_365": { "detected": false, "tenant_id": null }
},
"reasons": ["mx_google_workspace", "dmarc_reject", "txt_saas_tokens:3"],
"degraded": false
}
JavaScript with fetch
Node 18+, Deno, Bun and Cloudflare Workers have fetch built in. This helper fails open, so it’s safe to drop into a signup route:
// Server-side only: the secret key comes from the environment.
const API = 'https://api.isbusinessemail.com/v1/check';
export async function checkEmail(email, { policy = 'b2b', timeoutMs = 2500 } = {}) {
try {
const res = await fetch(API, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.IBE_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, policy }),
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) {
const problem = await res.json().catch(() => ({}));
console.warn('isBusinessEmail error', res.status, problem.code);
return { recommendation: 'allow', failOpen: true };
}
return await res.json();
} catch (err) {
console.warn('isBusinessEmail unreachable', err.name);
return { recommendation: 'allow', failOpen: true };
}
}
Errors come back as problem+json with a stable code, such as rate_limited, quota_exceeded or invalid_key, so log the code rather than the message.
JavaScript with @isbusinessemail/client
The official Node client is one file with no dependencies. It runs on Node 18+, Cloudflare Workers, Deno and Bun, and adds a 2-second timeout, one retry on network errors and 5xx (never on 429), and an in-memory cache of answers for 10 minutes:
npm install @isbusinessemail/client
import { createClient, isPersonal } from '@isbusinessemail/client';
const ibe = createClient({ apiKey: process.env.IBE_API_KEY });
// Signup: tryCheck() never throws. null means no answer, so fail open.
export async function signupVerdict(email) {
const r = await ibe.tryCheck(email);
return r ?? { recommendation: 'allow', failOpen: true };
}
// Mail pipeline: only "is the sender on a shared provider?"
export async function senderIsPersonal(address) {
return isPersonal(await ibe.tryCheck(address)); // true, false, or null when unknown
}
Use check() instead when you want errors: it throws an IbeError with status, code, retryAfter and requestId. More in Server-side integration.
Python with requests
import logging
import os
import requests
API = "https://api.isbusinessemail.com/v1/check"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['IBE_API_KEY']}"
def check_email(email: str, policy: str = "b2b") -> dict:
try:
r = session.post(API, json={"email": email, "policy": policy}, timeout=(2, 3))
if r.ok:
return r.json()
logging.warning("isBusinessEmail error %s", r.status_code)
except requests.RequestException as exc:
logging.warning("isBusinessEmail unreachable: %s", type(exc).__name__)
return {"recommendation": "allow", "fail_open": True}
result = check_email("jane@acme.io")
print(result["recommendation"], result.get("reasons"))
PHP with cURL
This uses the bundled cURL extension, with no Composer packages:
<?php
function check_email(string $email, string $policy = 'b2b'): array
{
$ch = curl_init('https://api.isbusinessemail.com/v1/check');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 2,
CURLOPT_TIMEOUT => 3,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('IBE_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode(['email' => $email, 'policy' => $policy]),
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($body === false || $status !== 200) {
error_log("isBusinessEmail failed: HTTP $status " . curl_error($ch));
return ['recommendation' => 'allow', 'fail_open' => true];
}
return json_decode($body, true);
}
$result = check_email('jane@acme.io');
echo $result['recommendation'], PHP_EOL;
Go, Ruby, Java and C# versions are in Code examples, and there are framework guides for Express, Django and Laravel.
Timeouts, failing open and caching
A few rules keep the check from ever becoming the reason a signup fails.
Keep the key on the server. Secret keys (ibe_live_…) belong in environment variables or a secret manager, and the API refuses them from browsers. For an inline hint while the user types, use a publishable key (ibe_pub_…) restricted to your site’s origin. It gets a reduced response and can be imitated outside a browser, so the real decision still happens on your server. The signup form guide shows the full pattern.
Set a short timeout. Two to three seconds is enough. The API gives its own DNS lookups a 1.5-second budget. If Google Workspace or Microsoft 365 detection doesn’t finish in time, the response arrives anyway with workspace.pending: true, and a later check has the full result.
Fail open. When the check can’t answer, let the user in and decide later:
| Situation | Do |
|---|---|
| Timeout or network error | Allow, flag the account, re-check in a background job |
429 (rate_limited or quota_exceeded) |
Allow, flag, re-check; alert if it keeps happening |
5xx |
Allow, flag, re-check |
401 or 403 |
Allow, and alert whoever is on call: it’s a key or configuration problem |
degraded: true |
Trust block for list matches; treat review as allow and re-check later |
Cache by domain. The API caches domain results at the edge (cached: true in the response), and the Node client keeps answers in memory for 10 minutes. On your side, a domain’s verdict rarely changes within a day. Don’t cache answers with degraded: true, and remember that is_role_account depends on the part before the @, so it can’t be cached per domain.
Send addresses in a POST body. With GET, addresses end up in URLs and proxy logs. Store the verdict (category, recommendation, reasons) on the account, not a copy of the whole response. Privacy and data explains what the API itself keeps.
Test without spending quota. Test addresses return fixed results for every branch: personal@, disposable@, unknown@, role@ and more under test.isbusinessemail.com. For timeouts and 429s, mock the HTTP client.
Checking many addresses at once
For imports, CRM clean-ups and back-filling existing users, use POST /v1/check/batch. It takes up to 100 email addresses or bare domains per request and returns results in the same order. Every item counts as one check toward your rate limits, duplicates included, so de-duplicate first. When you only care about the company, check domains instead of addresses: thousands of contacts often share far fewer domains.
import os
import time
import requests
API = "https://api.isbusinessemail.com/v1/check/batch"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['IBE_API_KEY']}"
def check_many(items: list[str], policy: str = "b2b") -> list[dict]:
items = list(dict.fromkeys(i.strip().lower() for i in items if i.strip())) # de-duplicate
results, i = [], 0
while i < len(items):
r = session.post(API, json={"items": items[i:i + 100], "policy": policy}, timeout=30)
if r.status_code == 429:
if r.json().get("code") == "quota_exceeded":
break # daily quota used up; resume after 00:00 UTC
time.sleep(float(r.headers.get("Retry-After", "1")))
continue # retry the same chunk
r.raise_for_status()
results.extend(r.json()["results"])
i += 100
return results
for item in check_many(["jane@acme.io", "bob@gmail.com", "contoso.com"]):
print(item["email"] or item["domain"], item["category"], item["recommendation"])
An item that isn’t a valid address or domain doesn’t fail the batch; it comes back with category: "invalid". In Node, the client’s checkBatch() does the chunking for you and skips anything already in its cache. No code at all? Upload a CSV to the bulk email checker. Details are in Batch checks.
Putting it together
A production setup usually looks like this:
- Validate syntax in the form, and label the field “Work email”.
- On submit, call the API from your server with a secret key and a 2 to 3 second timeout.
- Act on
recommendation, and showdid_you_meanas a clickable suggestion. - On any error, fail open: allow, flag, re-check in the background.
- Store
categoryon the account, and compare activation and conversion by category after a few weeks.
If all you need is a rough filter on a low-traffic form, the do-it-yourself baseline is a fine start. Refresh the list regularly and never block on a DNS error. For the product side of the decision (block, review or allow, and what the error message should say), see how to require a work email at sign-up. To put the same verdicts to work for sales, continue to lead scoring by email domain.
Frequently asked questions
How do I check if an email is a business email in JavaScript?
Take the domain after the @, compare it with a list of free email providers such as gmail.com, then call resolveMx from node:dns/promises to confirm the domain receives mail. A custom domain that is not on the list and has working MX records is very likely a work address.
Can a regex tell a business email from a personal one?
No. A regex can check syntax, but whether a domain is shared by many people or belongs to one company depends on lists and DNS, which change over time. Use a regex only to reject obviously malformed input.
How do I look up MX records in Python?
Install dnspython and call dns.resolver.resolve(domain, 'MX') with a lifetime set on the resolver. Catch NXDOMAIN, NoAnswer and timeouts separately, because they mean different things.
Is getmxrr enough to check an email in PHP?
Not on its own. getmxrr returns false for a missing domain, a domain without MX records and a DNS error alike, and it says nothing about whether the domain is a free provider. dns_get_record separates failures from empty answers.
Should I call an email classification API from the browser?
Not with a secret key. Make the deciding call from your server. If you want an inline hint while the user types, use a publishable key restricted to your site's origin, and still enforce the decision on the server.
What should my code do if the email check times out?
Fail open: let the signup through, flag the account for review and re-check it in a background job. A slow or unavailable third party should never stop real users from signing up.