Block fake signups with email validation

A server-side gate that sorts every new email address into allow, review or block, using two calls that cost the real user nothing and the throwaway account everything.

Every free tier attracts the same visitors: people who want the trial without the relationship. They sign up with [email protected], use what they came for, and leave you a row in the database, a bounced welcome email and a slightly wrong conversion rate. A few hundred of them and your metrics describe a product nobody uses.

You are going to build the piece of a registration flow that stops most of them. It is an Express route that takes an email address, asks two APIs about it, and returns one of three verdicts: allow, review or block. The signup form calls that route before it creates the account. Nothing here adds a step for a real person, which is the whole point: friction you add for fraudsters is friction your customers pay too.

By the end you will have a working server, a form that talks to it, and a clear answer to the question every gate eventually has to face: what happens when the check itself is down?

The two APIs and what they actually return

Two catalog APIs cover this, and they overlap on purpose.

Email Validator (emailvalidator) is the thorough one. It takes a single email query parameter on a GET and checks syntax, resolves the domain's mail servers, and classifies the address. Try it on a real company address:

Try it liveEmail Validator20 credits
GEThttps://api.apiverve.com/v1/emailvalidator
curl "https://api.apiverve.com/v1/emailvalidator?email=support%40myspace.com" \
  -H "x-api-key: YOUR_API_KEY"
Sign in or create a free account to run this with your own key.

isRegexValid, isMxValid and isDisposable are the three hard gates; isRoleAccount is true here.

The fields you will use, all present on every plan:

FieldMeaningWhat the gate does with it
isRegexValidThe address is well-formedfalse means block
isMxValidThe domain publishes mail serversfalse means block: you can never verify it
isNullMxThe domain explicitly says it accepts no mailtrue means block
isDisposableThe domain is a known throwaway providertrue means block
isRoleAccountsupport@, admin@, info@ and similarreview, never block
hasTypoThe address is malformed and fails format validation (not a spelling check)block
isFreeEmailGmail, Outlook and friendsignore, most real users have one

Two more fields, suggestedCorrection and riskScore/riskLevel, are premium. On plans that include them they are useful; on plans that do not, your code must still work. You will treat them as optional from the start.

Disposable Email Checker (emaildisposablechecker) answers one question and returns three fields: isDisposable, email and domain.

Try it liveDisposable Email Checker6 credits
GEThttps://api.apiverve.com/v1/emaildisposablechecker
curl "https://api.apiverve.com/v1/emaildisposablechecker?email=test%40mailinator.com" \
  -H "x-api-key: YOUR_API_KEY"
Sign in or create a free account to run this with your own key.

isDisposable is true. That one boolean is the whole answer.

So why use both, when the validator already has isDisposable? Cost. The catalog lists the validator at 20 credits per call and the disposable checker at 6. Throwaway addresses tend to arrive in bursts from the same few domains, and a disposable address is blocked regardless of anything else the validator could tell you. Asking the cheap question first, and only paying for the expensive one when the cheap answer is "not disposable", means every junk signup costs you 6 credits instead of 20.

Set up the project

Create a folder, install Express 4, and mark the project as an ES module so you can use import and top-level await.

mkdir signup-gate && cd signup-gate
npm init -y
npm pkg set type=module
npm install express@4
mkdir public

Your key goes in an environment variable, never in a file you commit and never in the browser. The form will talk to your server; only your server talks to APIVerve.

export APIVERVE_API_KEY=YOUR_API_KEY

On Windows PowerShell the equivalent is $env:APIVERVE_API_KEY = "YOUR_API_KEY".

Step 1: a small client with a short timeout

Every lesson in this course uses the same shape of helper: build the URL, send the key, enforce a timeout, unwrap the envelope, and throw a useful error when anything goes wrong. A signup gate needs a short timeout. A person is watching a spinner, and two seconds of waiting on a screening call is already a lot.

verve.js
const BASE = 'https://api.apiverve.com/v1';

export class VerveError extends Error {
  constructor(message, status, outOfCredits = false) {
    super(message);
    this.status = status;
    this.outOfCredits = outOfCredits;
  }
}

export async function verve(api, params, { method = 'GET', timeoutMs = 2000 } = {}) {
  const key = process.env.APIVERVE_API_KEY;
  if (!key) throw new Error('APIVERVE_API_KEY is not set');

  let url = `${BASE}/${api}`;
  const init = { method, headers: { 'x-api-key': key }, signal: AbortSignal.timeout(timeoutMs) };
  if (method === 'GET') {
    url += `?${new URLSearchParams(params)}`;
  } else {
    init.headers['Content-Type'] = 'application/json';
    init.body = JSON.stringify(params);
  }

  const res = await fetch(url, init);
  const body = await res.json().catch(() => null);

  if (!res.ok || body?.status !== 'ok') {
    const outOfCredits = res.status === 429 && res.headers.get('x-api-remaining-credits') === '0';
    throw new VerveError(body?.error || `HTTP ${res.status}`, res.status, outOfCredits);
  }
  return body.data;
}

Two status codes deserve to be told apart in your logs. A 401 means the key is missing or not recognised: a deploy problem, and retrying will not fix it. A 429 is either the per-minute rate limit or a month with no credits left, and the x-api-remaining-credits header settles which: 0 means out of credits. The helper records that on the error so the gate can log the right thing.

Step 2: the decision function

This is the heart of the project, and it is deliberately free of Express so you can test it on its own.

gate.js
import { verve } from './verve.js';

const DOMAIN_TTL_MS = 60 * 60 * 1000; // disposable status is a property of the domain
const disposableByDomain = new Map();

async function isDisposableDomain(email) {
  const domain = email.split('@').pop().toLowerCase();
  const hit = disposableByDomain.get(domain);
  if (hit && hit.expires > Date.now()) return hit.value;

  const { isDisposable } = await verve('emaildisposablechecker', { email });
  disposableByDomain.set(domain, { value: isDisposable, expires: Date.now() + DOMAIN_TTL_MS });
  return isDisposable;
}

export async function screenEmail(rawEmail) {
  const email = String(rawEmail ?? '').trim();
  if (!email.includes('@') || email.length > 254) {
    return { verdict: 'block', reasons: ['malformed'] };
  }

  // Cheap question first. A disposable domain is blocked no matter what else is true.
  if (await isDisposableDomain(email)) {
    return { verdict: 'block', reasons: ['disposable'] };
  }

  const v = await verve('emailvalidator', { email });

  const block = [];
  if (!v.isRegexValid) block.push('malformed');
  if (!v.isMxValid || v.isNullMx) block.push('cannot-receive-mail');
  if (v.isDisposable) block.push('disposable');
  if (block.length) return { verdict: 'block', reasons: block };

  const review = [];
  if (v.isRoleAccount) review.push('role-account');
  // A likely domain typo (gmial.com) shows up only in the premium suggestedCorrection.
  if (v.suggestedCorrection) review.push('possible-typo');

  return {
    verdict: review.length ? 'review' : 'allow',
    reasons: review,
    // Premium field: present on plans that include it, absent otherwise.
    suggestion: v.suggestedCorrection ?? null,
    normalized: v.normalizedEmail ?? email,
  };
}

Read the structure, not just the lines. There are hard gates (facts about the address that are true whoever typed it) and there are signals that only justify a closer look. A role account like [email protected] is a perfectly real mailbox; it is just rarely one person, so you might want to send it through email verification before granting a trial, but you have no business rejecting it. A typo is the customer's mistake, and the right response is a suggestion, not a door in their face.

The validator also checks isDisposable. That is not redundant: the dedicated checker's domain list and the validator's classification are maintained separately, and blocking on either is cheap insurance.

Check yourself

A signup arrives from [email protected]. The validator returns isMxValid true, isDisposable false and isRoleAccount true. What should the gate return?

Answer the check above to continue.

Run every example on your own key. Free plan, no card.

Scaling up?

Volume pricing, custom SLAs, and dedicated support for high-traffic teams.

Contact sales