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.

  • #engineering
  • #api
  • #signup
A code card calls check on jane@acme.io and gets back category business, is_business true and recommendation allow. JS Python PHP await check("jane@acme.io") { category "business" is_business true recommendation "allow" }

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:

  1. 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. and jane@acme.io must give the same answer.
  2. Compare it with a free-provider list. If the domain is gmail.com, outlook.com, gmx.de or another shared provider, the address is personal.
  3. 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.
Decision flow in three steps: extract the domain from jane@acme.io, check it against a free-provider list (a match means personal), then look up MX records, which ends in business for a custom MX, personal for a consumer MX, invalid for no domain or a null MX, and unknown for no MX or a DNS error 1 Extract the domain jane@acme.io → acme.io 2 On a free-provider list? yes personal no 3 Look up MX records business custom MX personal consumer MX invalid no domain, null MX unknown no MX, DNS error
The do-it-yourself check: three steps, four outcomes

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, review or block under your policy (b2b by default; strict and lenient are the others).
  • category: business, personal, disposable, relay, education, government, invalid or unknown.
  • did_you_mean: a suggested fix for typos such as gmial.com. Show it to the user; don’t apply it silently.
  • workspace.google_workspace.detected and workspace.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:

Request flow: the browser submits the form to your server, which calls /v1/check with a secret key and a 2.5-second timeout. A 200 answer is acted on; a timeout, 429 or 5xx means allow, flag for review and re-check in the background Browser form submit Your server ibe_live_… /v1/check 2.5 s timeout isBusinessEmail 200 OK act on recommendation timeout · 429 · 5xx allow and flag for review re-check in the background
Fail open: a slow check never blocks a signup
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:

  1. Validate syntax in the form, and label the field “Work email”.
  2. On submit, call the API from your server with a secret key and a 2 to 3 second timeout.
  3. Act on recommendation, and show did_you_mean as a clickable suggestion.
  4. On any error, fail open: allow, flag, re-check in the background.
  5. Store category on 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.