You block disposable emails with local checks, not with an API call per signup. A syntax test, a domain blocklist and an MX lookup cost nothing and run in milliseconds. LeadOcean fits after them, as a lookup that tells you who holds a work address. It is not a real-time signup verifier, and this guide says where that line sits.
TL;DR
- Three local checks (syntax, disposable domain, MX record) reject most throwaway signups. They take about 30 minutes to add and spend 0 LeadOcean records.
- An optional
GET /v2/people/reversecall tells you the person, company and email status behind a work address. Budget 1 record per lookup. - Use the lookup to route signups to sales. Do not use it to reject them: no match means unknown to us, not fake.
Prerequisites
- Node.js 18 or later. The scripts use built-in
fetchandnode:dns/promises. - A signup handler you can edit. The code below is one function you call before you create the user.
- Optional, for step 4: a LeadOcean API key, exported as
$LEADOCEAN_API_KEY. The free plan has 1,000 records, one-off, no card. Create the key at app.leadocean.io.
Step 1: Normalize and test the syntax
Lowercase the address, trim it and reject anything without a local part, an @ and a dotted domain. This catches typos like jane.doe@acme before any network call.
// signup-check.mjs
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function normalize(raw) {
const email = String(raw).trim().toLowerCase();
return EMAIL.test(email) ? { email, domain: email.split("@")[1] } : null;
}Keep the regex loose on purpose. A strict one rejects valid addresses, and the mailbox check in steps 2 and 3 does the real work.
Step 2: Check the domain against a disposable blocklist
Match the domain against the open-source disposable-email-domains list (checked September 2026). It holds second-level domains, one per line. Its README says xxx.yyy.zzz should match yyy.zzz, so test every parent suffix.
const LIST_URL =
"https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/main/disposable_email_blocklist.conf";
let blocked = new Set();
export async function loadBlocklist() {
const res = await fetch(LIST_URL);
if (!res.ok) throw new Error(`blocklist fetch failed: ${res.status}`);
const text = await res.text();
blocked = new Set(text.split("\n").map((s) => s.trim().toLowerCase()).filter(Boolean));
return blocked.size;
}
export function isDisposable(domain) {
const parts = domain.split(".");
for (let i = 0; i < parts.length - 1; i++) {
if (blocked.has(parts.slice(i).join("."))) return true;
}
return false;
}Call loadBlocklist() at boot and refresh it daily. The maintainers say they cannot guarantee every listed domain is still disposable, so log blocks and review complaints. A static list also misses new throwaway domains. That is the gap steps 3 and 4 narrow, not close.
Step 3: Require an MX record
A domain that cannot receive mail cannot confirm your signup link. Ask DNS for its MX records with resolveMx from Node's dns module (checked September 2026).
import { resolveMx } from "node:dns/promises";
export async function hasMx(domain) {
try {
return (await resolveMx(domain)).length > 0;
} catch {
return false;
}
}
export async function checkSignup(raw) {
const parsed = normalize(raw);
if (!parsed) return { ok: false, reason: "syntax" };
if (isDisposable(parsed.domain)) return { ok: false, reason: "disposable" };
if (!(await hasMx(parsed.domain))) return { ok: false, reason: "no_mx" };
return { ok: true, email: parsed.email, domain: parsed.domain };
}RFC 5321 lets a sender fall back to a domain's address record when no MX exists. Rejecting those domains is a product call. A resolveMx error can also be a DNS timeout, so retry once before you reject.
Step 4: Look up who holds a work address
Run this only for addresses that passed steps 1 to 3 and are not on a consumer domain. It answers one question: does a known person hold this address, and what is its status? The call is GET /v2/people/reverse with an email query parameter.
curl -s "https://api.leadocean.io/v2/people/reverse?email=jane.doe@acme.com" \
-H "x-api-key: $LEADOCEAN_API_KEY"The same call from Node, skipping free-mail domains so you do not spend records on them:
const CONSUMER = new Set(["gmail.com", "outlook.com", "yahoo.com", "icloud.com", "proton.me"]);
export async function lookupHolder({ email, domain }) {
if (CONSUMER.has(domain)) return null;
const url = new URL("https://api.leadocean.io/v2/people/reverse");
url.searchParams.set("email", email);
const res = await fetch(url, { headers: { "x-api-key": process.env.LEADOCEAN_API_KEY } });
if (res.status === 404) return null; // nobody holds it: unknown, not fake
if (!res.ok) throw new Error(`LeadOcean ${res.status}`);
return (await res.json()).data;
}Route on what comes back:
| Result | Meaning | Action |
|---|---|---|
matched.status is verified or catch_all_valid | A known person holds an address we checked | Create the user, flag as qualified |
matched.status is disposable, invalid, risky or spam_trap | We hold the address and it is not safe | Ask for a different address |
| 404 | No record holds it | Create the user, treat as unscored |
Two limits apply. LeadOcean's verifier has never tested a personal address: on personal addresses, verified means a supplier asserted it. And a fresh signup at a new startup may not be in the dataset yet. Never block on a 404.
What you get
The first output is from running steps 1 to 3 on 1 October 2026 against five placeholder addresses. The blocklist held 9,196 domains that day.
blocklist domains: 9196
jane.doe@gmail.com {"ok":true,"email":"jane.doe@gmail.com","domain":"gmail.com"}
jane.doe@acme {"ok":false,"reason":"syntax"}
jane@mailinator.com {"ok":false,"reason":"disposable"}
jane@sub.mailinator.com {"ok":false,"reason":"disposable"}
jane@no-such-domain-lo1531.example {"ok":false,"reason":"no_mx"}The second output is the response shape from the API reference for step 4, with placeholder values.
{
"success": true,
"data": {
"person_id": "PERSON_ID",
"full_name": "Jane Doe",
"job_title": "Head of Operations",
"company_name": "Acme",
"company_domain": "acme.com",
"query": { "email": "jane.doe@acme.com" },
"matched": {
"email": "jane.doe@acme.com",
"type": "work",
"status": "verified",
"verified_batch_date": "2026-09-20"
},
"matched_in_record": true
},
"meta": { "source": "leadocean" }
}For scale: on 21 October 2026 a free leadocean_count_leads call with emailStatus set to disposable returned 3,085 people. That counts people whose best stored email carries the disposable status, and passing emailStatus replaces the default email filter. It is a count of what we filtered, not a share of the database. The status is how LeadOcean labels addresses it already holds. It is not a check on your signup form.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing, wrong or revoked key | Send the key in the x-api-key header. Check $LEADOCEAN_API_KEY is set in the process that runs the handler. |
| 403 | The key lacks the scope for the endpoint, or the account is suspended | Create a key with the enrich scope. Free keys get both search and enrich. |
| 402 | Free plan: the 1,000 records are spent | They are one-off and do not reset. Move to Solo at $97 a month, or drop to local checks only. |
| 429 | Above your plan's rate limit per account (5 a second on Free, 50 on Solo, 200 on Agency) | Honour Retry-After. Queue lookups instead of firing one per keystroke. |
| 400 | Validation failed, for example both email and phone sent | Send exactly one of them. The response details names the field. |
| 404 | No record holds the address | Not an error in your code. Treat the signup as unscored. |
FAQ
Can a blocklist catch every disposable address?
No. New throwaway domains appear faster than any list updates, and the maintainers of the list above say so themselves. Pair the list with the MX check, and send a confirmation link. A user who opens it holds a working inbox.
Does LeadOcean verify an email at the signup form?
No. There is no endpoint that probes an arbitrary address in real time. LeadOcean stores a status for addresses in its dataset and returns it through GET /v2/people/reverse. See email verification explained for how that status is produced.
Should I reject free-mail signups too?
Only if your buyer is always a company. Gmail and Outlook addresses are real people. Rejecting them cuts abuse and also cuts solo founders and trial users. Decide on your funnel, not on the list.
How many records does a lookup cost?
Budget one record per GET /v2/people/reverse call. The free plan covers 1,000 once, and a lookup that finds nobody still costs one. Solo is $97 a month and Agency $497, with a fair-use pace instead of credits. See pricing.
Where do I read more on disposable addresses?
The glossary entry on the disposable email address defines the term. Our ranked guide to email verification tools covers vendors that do probe addresses in real time. More guides live in deliverability and verification.
Know who signed up before sales does
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →