Every free tier attracts the same visitors: people who want the trial without the relationship. They sign up with something@mailinator.com, 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?
01The 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.
02Set 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".
03Step 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.
04Step 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: null on plans that do not include it.
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 support@myspace.com 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 admin@acme-plumbing.com. The validator returns isMxValid true, isDisposable false and isRoleAccount true. What should the gate return?
05Step 3: the Express route and the form
The route's job is translation: take a JSON body, run the screen, and turn the verdict into a response the form can act on. This is also where you decide what happens when the API fails, so read the catch carefully.
import express from 'express';
import { screenEmail } from './gate.js';
import { VerveError } from './verve.js';
const app = express();
app.use(express.json());
app.use(express.static('public'));
app.post('/api/signup/check', async (req, res) => {
const { email } = req.body ?? {};
try {
const result = await screenEmail(email);
console.log(JSON.stringify({ at: new Date().toISOString(), email, ...result }));
res.json(result);
} catch (err) {
// Fail open: the screen is down, the signup form is not.
const why = err instanceof VerveError
? (err.outOfCredits ? 'out-of-credits' : `api-${err.status}`)
: err.name === 'TimeoutError' ? 'timeout' : 'network';
console.error(JSON.stringify({ at: new Date().toISOString(), email, screenFailed: why }));
res.json({ verdict: 'review', reasons: ['screen-unavailable'] });
}
});
app.listen(3000, () => console.log('http://localhost:3000'));The form submits to that route first. The browser never sees your key; it only sees the verdict.
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Sign up</title></head>
<body>
<form id="signup">
<label>Email <input name="email" type="email" required autocomplete="email"></label>
<button>Create account</button>
<p id="msg" role="status"></p>
</form>
<script>
const form = document.getElementById('signup');
const msg = document.getElementById('msg');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const email = form.email.value;
msg.textContent = 'Checking...';
const res = await fetch('/api/signup/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
const { verdict, reasons, suggestion } = await res.json();
if (verdict === 'block') {
msg.textContent = reasons.includes('disposable')
? 'Please use a permanent email address.'
: 'That address cannot receive email. Check it and try again.';
return;
}
if (suggestion && form.dataset.offered !== email) {
form.dataset.offered = email;
msg.textContent = `Did you mean ${suggestion}? Submit again to keep what you typed.`;
return;
}
// allow or review: create the account. Review means "verify the email before the trial starts".
msg.textContent = verdict === 'allow' ? 'Account created.' : 'Check your inbox to confirm your address.';
});
</script>
</body>
</html>Run it with node server.js, open http://localhost:3000, and try three addresses: a real one you own, test@mailinator.com, and something on a domain with no mail servers. Watch the log line for each.
06Fail open or fail closed
Sooner or later the screening call will time out, or your month's credits will run out on the 29th. The route above fails open: it lets the signup through as review and logs why. That is the right default for almost every product, and it is worth being precise about why.
The screen exists to keep junk out of your database. Registration exists to let customers in. If an outage in the first one becomes an outage in the second, you have converted a data-quality problem into a revenue problem, and you will not notice until someone asks why signups were zero on Tuesday. A junk account that slipped through during a ten-minute outage costs you a row. A real customer who hit an error page costs you the customer.
Notice that "fail open" here does not mean "allow". It means review, which in this flow means the trial waits for email confirmation. You still get a gate, just a slower, human-shaped one.
Fail closed only when an accepted fake costs more than a rejected customer. If each new account gets real compute, a phone number, or money, a burst of fraudulent signups during an outage can cost more than an hour of lost conversions. In that case, reject with a clear "try again in a few minutes" message rather than a vague error, and alert yourself immediately.
Your screening call times out during a registration. The product gives every new account a free tier with no upfront cost to you. What should the gate do?
07Harden 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.
08Ideas 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.