Pre-Black Friday: 20% off, for as long as you stayEARLYBFon Solo and AgencyEnds Nov 28Start free

How-to guide

How to Block Disposable Emails at SaaS Signup (Node and LeadOcean)

Three local checks stop throwaway and dead addresses at the form. One optional API call tells you whether a work address belongs to a known person.

Get your free API key →Free to start. No credit card. 1,000 records to spend whenever you like.

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/reverse call 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 fetch and node: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.

js
// 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.

js
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).

js
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.

bash
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:

js
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:

ResultMeaningAction
matched.status is verified or catch_all_validA known person holds an address we checkedCreate the user, flag as qualified
matched.status is disposable, invalid, risky or spam_trapWe hold the address and it is not safeAsk for a different address
404No record holds itCreate 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.

text
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.

json
{
  "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

ErrorCauseFix
401Missing, wrong or revoked keySend the key in the x-api-key header. Check $LEADOCEAN_API_KEY is set in the process that runs the handler.
403The key lacks the scope for the endpoint, or the account is suspendedCreate a key with the enrich scope. Free keys get both search and enrich.
402Free plan: the 1,000 records are spentThey are one-off and do not reset. Move to Solo at $97 a month, or drop to local checks only.
429Above 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.
400Validation failed, for example both email and phone sentSend exactly one of them. The response details names the field.
404No record holds the addressNot 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 →