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:
curl "https://api.apiverve.com/v1/emailvalidator?email=support%40myspace.com" \
-H "x-api-key: YOUR_API_KEY"isRegexValid, isMxValid and isDisposable are the three hard gates; isRoleAccount is true here.
The fields you will use, all present on every plan:
| Field | Meaning | What the gate does with it |
|---|---|---|
isRegexValid | The address is well-formed | false means block |
isMxValid | The domain publishes mail servers | false means block: you can never verify it |
isNullMx | The domain explicitly says it accepts no mail | true means block |
isDisposable | The domain is a known throwaway provider | true means block |
isRoleAccount | support@, admin@, info@ and similar | review, never block |
hasTypo | The address is malformed and fails format validation (not a spelling check) | block |
isFreeEmail | Gmail, Outlook and friends | ignore, 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.
curl "https://api.apiverve.com/v1/emaildisposablechecker?email=test%40mailinator.com" \
-H "x-api-key: YOUR_API_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 publicYour 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_KEYOn 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.
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.
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.
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.
Harden it
A few changes turn this from a demo into something you would deploy.
Keep the timeout short and singular. Two seconds per call, at most two calls. Do not add retries inside the signup request; a retry doubles the worst-case wait. If you want retries, do them in a background job that re-screens review accounts later.
Log the inputs to the verdict, not just the verdict. When a real customer emails to say they were rejected, a log line with isMxValid: false tells you in seconds whether their domain was misconfigured or your rule was wrong. A log line that says only block tells you nothing.
Re-screen after an outage. Because failures come back as reasons: ['screen-unavailable'], you can find every account admitted without a check and run them through screenEmail again once the API is back.
Rate-limit the route itself. This endpoint spends credits on behalf of anonymous visitors. Someone scripting it can burn through your monthly allowance. Put a per-IP rate limit and, if you are a target, a CAPTCHA in front of it. The cache helps with repeats, but it does nothing against a list of unique addresses.
Do not cache per address. The domain cache is safe because disposable status belongs to the domain. Per-address results like suggestedCorrection belong to the input, and caching them gains you little.
For the full set of status codes and what each one means, see error handling and rate limits.
Ideas to extend it
- Score instead of branch. Replace the review list with a number, record it against every signup for a couple of weeks, and pick your threshold from real data instead of a guess.
- Inline typo hints. Call your route on
blurof the email field and showsuggestedCorrectionwhere your plan includes it, before the person even submits. - Check the phone number too. If your form collects one, the Phone Number Validator follows the same allow, review, block pattern.
- Screen a backlog. Run
screenEmailover your existing users in a script and see how many of last year's signups would have been stopped.
Next you will build something users actually look at: a widget that finds a visitor's city from their IP address and shows them the forecast. Continue to Build a weather widget.