Email verification checks whether an address can actually receive mail — before you let it into your database. It catches typos (hotmial.com), throwaway inboxes, and dead mailboxes at the exact moment of signup. VerifyAnyEmail performs a live SMTP probe without ever sending a message and hands you back a single status: deliverable, undeliverable, risky, or unknown. Your job in Node is to make one HTTP call and decide what to do with that word.
Why this belongs on the server, not the client
The rule that overrides everything else: never call the verification API from the browser. Anything you ship to the client — React code, a fetch in the frontend bundle, a mobile app — is readable by anyone, and a leaked vae_... key means someone else spending your credits. Express runs on the server, so your key stays in process.env and never crosses to the client. Read your response, return only a safe boolean or message to the browser. More on key hygiene in how to protect your API keys.
The call is always POST https://api.verifyany.email/v1/verify with an Authorization: Bearer vae_... header and a JSON body of { "email": "name@example.com" }. The full response schema is in the API docs.
The raw request
Start with the bare call so you can see exactly what happens. On Node 18 and newer, fetch is global — no node-fetch, no axios required.
// One-off: the raw fetch call, so you can see exactly what goes over the wire.
// Node 18+ has fetch built in — no dependency needed. Server-side only.
const res = await fetch('https://api.verifyany.email/v1/verify', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.VAE_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ email: 'deliverable@test.verifyany.email' }),
});
const data = await res.json();
console.log(data.ok, data.result.status); // true "deliverable"A reusable verifyEmail() client
Don't scatter raw fetches through your routes. Wrap the call once, add a timeout, and make it swallow every error into an unknown status so a flaky network can never take down your signup path. This is the function you actually import.
// lib/verifyEmail.js — a small reusable client. Import it anywhere on the server.
// It NEVER throws: every failure path returns { status: 'unknown' } so a slow or
// down API can't crash your signup. Set a timeout so a request can't hang forever.
export async function verifyEmail(email, { timeoutMs = 10000 } = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch('https://api.verifyany.email/v1/verify', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.VAE_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
signal: controller.signal,
});
// Rate limited or server error → fail open.
if (res.status === 429 || res.status >= 500) {
return { status: 'unknown', error: `http_${res.status}` };
}
const data = await res.json();
if (!data.ok) return { status: 'unknown', error: data.error };
return data.result; // { status, subStatus, score, suggestion, safeToSend, ... }
} catch {
// Abort (timeout), DNS failure, connection reset — all fail open.
return { status: 'unknown', error: 'request_failed' };
} finally {
clearTimeout(timer);
}
}Two production details worth calling out. The AbortController enforces a hard timeout — without it, a stalled connection could hold your request open indefinitely. And every branch returns the same shape ({ status }), so callers never have to wrap it in a try/catch of their own.
Using it in an Express route
Now the client plugs straight into a signup handler. Do the cheap local check first (is there an @ at all?), then spend a probe only on plausible input, then branch on the status.
// routes/signup.js
import express from 'express';
import { verifyEmail } from '../lib/verifyEmail.js';
const router = express.Router();
router.post('/signup', async (req, res) => {
const { email } = req.body;
// Cheap syntax gate first — don't spend a probe on obvious garbage.
if (typeof email !== 'string' || !email.includes('@')) {
return res.status(400).json({ error: 'Enter a valid email address.' });
}
const result = await verifyEmail(email);
switch (result.status) {
case 'undeliverable':
// Definite negative: block and let the user correct it.
return res.status(422).json({
error: 'That email address does not exist.',
suggestion: result.suggestion ?? null, // e.g. "gmail.com" for a typo
});
case 'risky':
// Catch-all / role / disposable: accept, but tag for review.
req.body.emailRisk = result.subStatus;
break;
// 'deliverable' and 'unknown' both fall through and proceed.
// Never block on 'unknown' — that would reject real users on a timeout.
}
// ... create the user here ...
return res.status(201).json({ ok: true });
});
export default router;How to branch on result.status
Four statuses map to four actions. The policy matters more than the plumbing:
- deliverable — real, reachable mailbox. Accept.
- undeliverable — mailbox doesn't exist or the domain can't receive mail. Block, and surface
result.suggestionif present (it's the "did you mean" domain fix for typos). - risky — catch-all domain, role address (
info@,sales@), or disposable inbox. Accept but tag withsubStatusso you can review or segment later. - unknown — greylisting, timeout, temporary SMTP failure. Fail open: always let the signup through and re-verify later. Blocking on
unknownrejects real people over a transient hiccup.
That fail-open stance is exactly what keeps verification from costing you signups — see verify emails at signup without hurting conversion for the UX side.
Testing without spending credits
Any address at @test.verifyany.email returns a fixed result and costs zero credits — no real mailbox is probed. Perfect for unit tests and local dev:
deliverable@test.verifyany.email→status: deliverableundeliverable@test.verifyany.email→status: undeliverablerisky@test.verifyany.email→status: riskyunknown@test.verifyany.email→status: unknown
Write one Jest or node:test case per branch by pointing verifyEmail() at each address — full coverage of your accept/flag/block logic, no credits burned. You can also spot-check any address in the Email Verifier.
Errors, timeouts and retries
- Always time out. The client above aborts at 10 seconds. A verification call must never be able to hang an HTTP request.
- Fail open on 429 and 5xx. Return
unknownand proceed. If you retry inline, retry once with a short backoff — don't hammer a rate limit. - Don't retry 400 or 401. A
400means malformed input and401means a bad key; both are bugs to fix, not transient errors. (402means you're out of credits.) - Verifying a whole list? Don't loop this endpoint. Use the async batch endpoint and a signed webhook — see processing large verification batches. When you receive that webhook, validate its HMAC signature; the Webhook Signature Tester helps you confirm your check is correct.
Quick FAQ
Does this send an email to the user? No. The SMTP probe stops before any message is delivered — nothing reaches the recipient.
Do I need axios or node-fetch? No. Global fetch has shipped in Node since v18. Only reach for a library if you're on an older runtime.
Should I await verification inside the request, or queue it? For a single signup, awaiting with a 10-second timeout is fine and gives instant feedback. For bulk imports, always queue and use the batch API.
On a different stack? See the sibling guides for PHP and Laravel and Python (Django and FastAPI), and the full API documentation.
Sources
Node's global fetch and AbortController handle the request and timeout; routing uses Express.