Verify an email address in Node.js with fetch: single, batch and backoff
· 4 min read · Tutorials
To verify an email address in Node.js, call a verification API with the global fetch that ships in Node 18 and newer, and read the status it returns: valid, invalid, risky or unknown. No npm package needed. Below is a single-address check for signup handlers, then a list verifier that splits addresses into chunks of 50 for a batch endpoint and backs off on HTTP 429. It uses pingutil's /v1/verify API.
node --version # v18 or newer
export PINGUTIL_KEY=pk_your_key_here
Verify one address
// verify-one.mjs
const KEY = process.env.PINGUTIL_KEY;
export async function verifyEmail(email) {
const res = await fetch('https://pingutil.com/v1/verify', {
method: 'POST',
headers: { authorization: `Bearer ${KEY}`, 'content-type': 'application/json' },
body: JSON.stringify({ email }),
signal: AbortSignal.timeout(60_000),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(`${res.status} ${err.error ?? ''}: ${err.message ?? res.statusText}`);
}
return res.json();
}
if (import.meta.url === `file://${process.argv[1]}`) {
const r = await verifyEmail(process.argv[2]);
console.log(r.status, r.reason, r.score, r.did_you_mean ?? '');
}
node verify-one.mjs jane@hotmial.com
The result has status, reason, a 0 to 100 score, did_you_mean (the corrected address when the domain looks like a typo of a big provider), and a checks object with the facts behind the decision: syntax, disposable, free_provider, role_based, randomized, domain_exists, has_mx, mailbox, catch_all.
In a signup route, reject only on invalid and show did_you_mean as a suggestion. Treat risky and unknown as "let them in, confirm by email". Catching email typos at signup covers the UI side. If the verification call itself fails (network, timeout), let the signup through rather than blocking real users on an outage.
Verify a list in chunks of 50
POST /v1/verify/batch takes {"emails": [...]} with up to 50 addresses and returns {"results": [...]} in input order. The script below reads one address per line, verifies them in chunks and writes a CSV.
It treats the two kinds of 429 differently. rate_limited (over 60 requests a minute per account) means wait and retry; the body includes retry_after_seconds, and the code also honours a standard Retry-After header if one is present, as RFC 6585 allows. quota_exceeded (the account's 300 verifications a day are used, reset at 00:00 UTC) means stop, so the script saves what it has.
// verify-list.mjs
// usage: node verify-list.mjs emails.txt results.csv
import { readFile, writeFile } from 'node:fs/promises';
const KEY = process.env.PINGUTIL_KEY;
if (!KEY) throw new Error('set PINGUTIL_KEY');
const BATCH_URL = 'https://pingutil.com/v1/verify/batch';
const CHUNK = 50;
const MAX_RETRIES = 5;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
class QuotaExceeded extends Error {}
function retryDelayMs(res, body, attempt) {
const header = Number(res.headers.get('retry-after'));
if (Number.isFinite(header) && header > 0) return header * 1000;
if (body?.retry_after_seconds) return body.retry_after_seconds * 1000;
return Math.min(2 ** attempt * 1000, 60_000) + Math.random() * 500;
}
async function verifyBatch(emails) {
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
let res;
try {
res = await fetch(BATCH_URL, {
method: 'POST',
headers: { authorization: `Bearer ${KEY}`, 'content-type': 'application/json' },
body: JSON.stringify({ emails }),
signal: AbortSignal.timeout(120_000),
});
} catch (e) {
await sleep(Math.min(2 ** attempt * 1000, 60_000)); // network error or timeout
continue;
}
const body = await res.json().catch(() => null);
if (res.ok) return body.results;
if (res.status === 401) throw new Error('401 unauthorized: check PINGUTIL_KEY');
if (res.status === 429 && body?.error === 'quota_exceeded') throw new QuotaExceeded(body.message);
if (res.status === 429 || res.status >= 500) {
const ms = retryDelayMs(res, body, attempt);
console.error(`${res.status} ${body?.error ?? ''}, retrying in ${Math.round(ms / 1000)}s`);
await sleep(ms);
continue;
}
throw new Error(`${res.status} ${body?.error}: ${body?.message}`);
}
throw new Error(`gave up after ${MAX_RETRIES} attempts`);
}
const csvCell = (v) => {
const s = v == null ? '' : String(v);
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
const [input, output] = process.argv.slice(2);
const emails = [
...new Set(
(await readFile(input, 'utf8')).split(/\r?\n/).map((l) => l.trim()).filter(Boolean),
),
];
const rows = [];
try {
for (let i = 0; i < emails.length; i += CHUNK) {
const chunk = emails.slice(i, i + CHUNK);
const results = await verifyBatch(chunk);
chunk.forEach((email, j) => rows.push({ email, ...results[j] }));
console.error(`${rows.length}/${emails.length} verified`);
}
} catch (e) {
if (!(e instanceof QuotaExceeded)) throw e;
console.error(`stopping early: ${e.message}`);
}
const cols = ['email', 'status', 'reason', 'score', 'did_you_mean', 'mx_host'];
const csv = [cols.join(','), ...rows.map((r) => cols.map((c) => csvCell(r[c])).join(','))];
await writeFile(output, csv.join('\n') + '\n');
console.error(`wrote ${output}: ${rows.length} of ${emails.length} addresses`);
node verify-list.mjs emails.txt results.csv
Notes on the choices:
- Sequential chunks, not
Promise.all. The rate limit is per account, and the daily budget is the real ceiling, so parallel requests only get you to the 429 faster. - Dedupe before sending. Every address you send counts toward the daily budget, including ones pingutil answers from its 30-day cache (
cached: truein the result). - Jitter on backoff. The random half-second stops several workers from retrying in lockstep if you do run more than one.
AbortSignal.timeoutis available from Node 17.3, so it's safe on any Node 18+ runtime. Mailbox checks wait on remote mail servers, so keep the timeout generous.
What to do with the results
Split the CSV by status: send to valid, suppress invalid, and review risky and unknown by reason instead of deleting them. accept_all means a catch-all domain, which is explained in catch-all email domains, and the full reason list is in email verification statuses explained.
The same approach in Python, with CSV columns preserved, is in verify email addresses in Python. Field reference: /api#verify. Try addresses by hand first in the verification demo.
Questions people ask
- Do I need an npm package to verify emails in Node.js?
- Not for calling an API. Node 18 and newer ship a global fetch, so a few lines of code can call a verification endpoint. Packages that verify mailboxes themselves need outbound SMTP on port 25, which many hosts block.
- Should I verify emails in the browser or on the server?
- On the server. The API key must stay secret, so call the verification API from your backend or a serverless function and return only what the form needs, such as a status and a typo suggestion.