How to verify email addresses in Node.js and Express

In Node.js you verify an email by sending one POST request to a verification API from your server and branching on the status it returns. Plainly: when a user submits your form, your Express backend asks the verification service whether the address is a real, reachable mailbox, then accepts, flags, or rejects the signup. Node 18+ has fetch built in, so you need no extra dependency — below is a raw example plus a small reusable client you can drop into any route.

Written & reviewed by the VerifyAnyEmail team · Last reviewed July 2026

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.suggestion if 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 with subStatus so 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 unknown rejects 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.emailstatus: deliverable
  • undeliverable@test.verifyany.emailstatus: undeliverable
  • risky@test.verifyany.emailstatus: risky
  • unknown@test.verifyany.emailstatus: 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 unknown and proceed. If you retry inline, retry once with a short backoff — don't hammer a rate limit.
  • Don't retry 400 or 401. A 400 means malformed input and 401 means a bad key; both are bugs to fix, not transient errors. (402 means 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.

Check your domain in seconds

Run the free diagnostics referenced in this guide — no sign-up needed.