How to Require a Work Email at Signup Without Blocking Real Customers
A work-email gate should answer one narrow question: is the submitted domain a mainstream free provider, a disposable provider, or another domain that appears able to receive mail? It cannot prove that the person works for the company in the domain.
The runnable Node.js route below keeps that boundary explicit. A company signup using Gmail is asked for a work address, but the same person can continue through an individual-account path. Disposable domains are blocked, domains without mail routing are rejected, and inconclusive routing goes to confirmation or review.
Start with the decision, not a domain blocklist
The policy has four observable results:
allow: continue to account creation.work_email_required: request a company-domain address while preserving the individual option.review: continue with email confirmation or restricted access because domain routing was inconclusive.block: reject a disposable domain or a domain that cannot receive mail.
The important input is accountType. Applying the work-email rule only to company onboarding prevents a B2B qualification rule from accidentally closing consumer or individual signup.
export function decideSignup(report, accountType) {
if (report.classification === 'disposable') {
return {
statusCode: 422,
decision: 'block',
code: 'DISPOSABLE_EMAIL',
message: 'Use a permanent email address.'
};
}
if (report.mail_routing === 'unavailable') {
return {
statusCode: 422,
decision: 'block',
code: 'DOMAIN_CANNOT_RECEIVE_MAIL',
message: 'Use an email domain that can receive mail.'
};
}
if (
report.classification === 'free_provider' &&
accountType === 'company'
) {
return {
statusCode: 422,
decision: 'work_email_required',
code: 'WORK_EMAIL_REQUIRED',
message: 'Use your work email, or continue with an individual account.'
};
}
if (report.mail_routing === 'unknown') {
return {
statusCode: 202,
decision: 'review',
code: 'DOMAIN_CHECK_INCONCLUSIVE',
message: 'Continue with email confirmation before granting access.'
};
}
return {
statusCode: 200,
decision: 'allow',
code: 'EMAIL_DOMAIN_ALLOWED',
message: 'Continue to account creation.'
};
}
A free-provider result is not an invalid-email result. Gmail, Outlook, Yahoo, Proton Mail, and similar providers serve legitimate long-term inboxes. The rule asks for a different address only because company affiliation matters to this particular flow. The disposable-email policy guide explains why free and disposable providers need separate treatment.
Build the dependency-free Node.js server
You need Node.js 22 or newer. Create an empty directory with these four files:
mkdir work-email-signup
cd work-email-signup
touch package.json app.mjs server.mjs app.test.mjs
Use this package.json. Node's built-in HTTP server, fetch, environment-file support, and test runner keep the example free of third-party packages. The relevant APIs are documented in the current Node.js references for HTTP, environment variables, and testing.
{
"type": "module",
"scripts": {
"start": "node --env-file=.env server.mjs",
"test": "node --test"
}
}
Create app.mjs:
import { createServer } from 'node:http';
const domainApiUrl = 'https://emailverifier.dev/api/v1/domains';
export function extractDomain(email) {
if (typeof email !== 'string') return null;
const value = email.trim().toLowerCase();
const at = value.indexOf('@');
if (
at <= 0 ||
at !== value.lastIndexOf('@') ||
at === value.length - 1 ||
/\s/.test(value)
) {
return null;
}
const domain = value.slice(at + 1);
const domainPattern =
/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
return domainPattern.test(domain) ? domain : null;
}
export function decideSignup(report, accountType) {
if (report.classification === 'disposable') {
return {
statusCode: 422,
decision: 'block',
code: 'DISPOSABLE_EMAIL',
message: 'Use a permanent email address.'
};
}
if (report.mail_routing === 'unavailable') {
return {
statusCode: 422,
decision: 'block',
code: 'DOMAIN_CANNOT_RECEIVE_MAIL',
message: 'Use an email domain that can receive mail.'
};
}
if (
report.classification === 'free_provider' &&
accountType === 'company'
) {
return {
statusCode: 422,
decision: 'work_email_required',
code: 'WORK_EMAIL_REQUIRED',
message: 'Use your work email, or continue with an individual account.'
};
}
if (report.mail_routing === 'unknown') {
return {
statusCode: 202,
decision: 'review',
code: 'DOMAIN_CHECK_INCONCLUSIVE',
message: 'Continue with email confirmation before granting access.'
};
}
return {
statusCode: 200,
decision: 'allow',
code: 'EMAIL_DOMAIN_ALLOWED',
message: 'Continue to account creation.'
};
}
export async function classifyDomain(
domain,
{
apiKey = process.env.EMAILVERIFIER_API_KEY,
fetchImpl = fetch
} = {}
) {
if (!apiKey) {
throw new Error('EMAILVERIFIER_API_KEY is not set');
}
const response = await fetchImpl(
`${domainApiUrl}/${encodeURIComponent(domain)}`,
{
headers: { 'X-API-Key': apiKey },
signal: AbortSignal.timeout(4000)
}
);
if (!response.ok) {
throw new Error(`Domain check failed with HTTP ${response.status}`);
}
return response.json();
}
export async function handleSignup(input, dependencies = {}) {
const domain = extractDomain(input?.email);
const accountType = input?.accountType;
if (!domain || !['company', 'individual'].includes(accountType)) {
return {
statusCode: 400,
body: {
decision: 'invalid_request',
message: 'Send a valid email and accountType of company or individual.'
}
};
}
const classify = dependencies.classify ?? classifyDomain;
const report = await classify(domain);
const result = decideSignup(report, accountType);
return {
statusCode: result.statusCode,
body: {
...result,
domain: report.domain,
classification: report.classification,
mailRouting: report.mail_routing
}
};
}
async function readJson(request) {
const chunks = [];
let size = 0;
for await (const chunk of request) {
size += chunk.length;
if (size > 16_384) throw new Error('Request body is too large');
chunks.push(chunk);
}
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
}
function sendJson(response, statusCode, body) {
response.writeHead(statusCode, {
'Content-Type': 'application/json; charset=utf-8'
});
response.end(JSON.stringify(body));
}
export function createApp(dependencies = {}) {
return createServer(async (request, response) => {
if (request.method !== 'POST' || request.url !== '/signup') {
sendJson(response, 404, { message: 'Not found' });
return;
}
let input;
try {
input = await readJson(request);
} catch {
sendJson(response, 400, {
decision: 'invalid_request',
message: 'Send a valid JSON request body.'
});
return;
}
try {
const result = await handleSignup(input, dependencies);
sendJson(response, result.statusCode, result.body);
} catch (error) {
console.error(error);
sendJson(response, 503, {
decision: 'verification_unavailable',
message: 'Try signup again shortly.'
});
}
});
}
classifyDomain contains the route's only external request. The project key remains in the Node.js process, and the four-second timeout keeps an unavailable dependency separate from an email decision.
Create server.mjs:
import { createApp } from './app.mjs';
const port = Number(process.env.PORT ?? 3000);
createApp().listen(port, () => {
console.log(`Signup server listening on http://localhost:${port}`);
});
Read the domain result without overclaiming it
GET /api/v1/domains/{domain} returns three independent fields:
| Field | Possible values | What it answers |
|---|---|---|
classification |
disposable, free_provider, unknown |
Whether the domain is in a maintained provider category. |
risk |
high, medium, unknown |
The domain-level risk summary, not a claim about the person. |
mail_routing |
available, unavailable, unknown |
Whether the domain appears able to receive mail. |
An unknown classification means the domain is not recognized as disposable or as a mainstream free provider. It may be a company domain, a private domain, or a new provider. The result does not prove employment, domain ownership, or that a specific mailbox exists.
If mailbox evidence matters too, follow domain classification with full-address verification in the flow that actually grants access. The validation, verification, and confirmation guide keeps those layers separate. Handle addresses such as sales@company.example with the role-address policy rather than treating the whole company domain as invalid.
Test every branch without spending credits
Create app.test.mjs:
import assert from 'node:assert/strict';
import test from 'node:test';
import {
classifyDomain,
decideSignup,
extractDomain,
handleSignup
} from './app.mjs';
test('extractDomain rejects malformed input', () => {
assert.equal(extractDomain('not-an-email'), null);
assert.equal(extractDomain('two@@example.com'), null);
assert.equal(extractDomain('person@example.com'), 'example.com');
});
test('company signup with a free provider requests a work email', async (t) => {
const classify = t.mock.fn(async (domain) => ({
domain,
classification: 'free_provider',
risk: 'medium',
mail_routing: 'available'
}));
const result = await handleSignup(
{ email: 'owner@gmail.com', accountType: 'company' },
{ classify }
);
assert.equal(classify.mock.callCount(), 1);
assert.equal(result.statusCode, 422);
assert.equal(result.body.decision, 'work_email_required');
});
test('individual signup can use a free provider', () => {
const result = decideSignup(
{ classification: 'free_provider', mail_routing: 'available' },
'individual'
);
assert.equal(result.statusCode, 200);
assert.equal(result.decision, 'allow');
});
test('disposable domains are blocked', () => {
const result = decideSignup(
{ classification: 'disposable', mail_routing: 'available' },
'company'
);
assert.equal(result.statusCode, 422);
assert.equal(result.code, 'DISPOSABLE_EMAIL');
});
test('inconclusive mail routing goes to confirmation or review', () => {
const result = decideSignup(
{ classification: 'unknown', mail_routing: 'unknown' },
'company'
);
assert.equal(result.statusCode, 202);
assert.equal(result.decision, 'review');
});
test('classifyDomain sends one authenticated request', async (t) => {
const fetchImpl = t.mock.fn(async (url, options) => {
assert.equal(
url,
'https://emailverifier.dev/api/v1/domains/example.com'
);
assert.equal(options.headers['X-API-Key'], 'project-key');
return {
ok: true,
json: async () => ({
domain: 'example.com',
classification: 'unknown',
risk: 'unknown',
mail_routing: 'available'
})
};
});
await classifyDomain('example.com', {
apiKey: 'project-key',
fetchImpl
});
assert.equal(fetchImpl.mock.callCount(), 1);
});
Run the suite:
npm test
All six tests should pass. The test double also proves that one company signup produces exactly one classification request.
Make the live signup request
Create an emailverifier.dev account and project, then copy its API key. Add a local .env file:
EMAILVERIFIER_API_KEY=your-project-key
Start the server:
npm start
From another terminal, submit a company signup:
curl http://localhost:3000/signup \
-H "Content-Type: application/json" \
-d '{
"email": "owner@gmail.com",
"accountType": "company"
}'
The route returns 422 with work_email_required. Changing accountType to individual allows the same long-term Gmail address. Replace the marked account-creation boundary after allow; keep review on a confirmation or restricted-access path, and keep transport failures at 503 instead of turning them into email rejections.