Email verification in Node.js
Node.js has had fetch built in since 18, so verification needs no HTTP dependency at all. Everything below runs on a stock Node with no packages installed.
The one thing fetch will not do for you is time out. There is no default, and a request to a mail host that has decided to be slow will sit there. AbortSignal.timeout is the fix and it appears in every sample here.
Built-in fetch with AbortSignal.timeout. No axios, no node-fetch, no SDK to keep up to date.
Verify one address
You branch on four results: valid, invalid, catch_all and unknown. Role and disposable are boolean flags beside the result, so test them separately. Unknown is the branch most code forgets, and it is never billed.
const KEY = process.env.ZAPBOUNCE_KEY;
async function verify(email) {
const res = await fetch("https://api.zapbounce.com/v1/verify", {
method: "POST",
headers: {
Authorization: `Bearer ${KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
signal: AbortSignal.timeout(10_000), // fetch has no default. Set one.
});
if (!res.ok) {
const { error } = await res.json();
throw Object.assign(new Error(error.message), { code: error.code, status: res.status });
}
return res.json();
}
const r = await verify("ada@example.com");
switch (r.result) {
case "valid": await send(r.email); break;
case "catch_all": await queueSeparately(r.email); break; // accepts every address
case "unknown": await retryTomorrow(r.email); break; // free, and not a verdict
default: await suppress(r.email);
}Verify a list
Submit a batch rather than looping the single endpoint. We pace probes per receiving mail host, which protects the sending reputation your results depend on.
async function submitBatch(emails, name) {
const res = await fetch("https://api.zapbounce.com/v1/batches", {
method: "POST",
headers: {
Authorization: `Bearer ${KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(), // a lost response must not bill twice
},
body: JSON.stringify({ name, emails, webhook_url: process.env.HOOK_URL }),
signal: AbortSignal.timeout(30_000),
});
return res.json();
}
/** Async iterator over the cursor, so callers can for-await without buffering. */
async function* batchResults(batchId) {
let cursor;
do {
const url = new URL(`https://api.zapbounce.com/v1/batches/${batchId}/results`);
url.searchParams.set("limit", "1000");
if (cursor) url.searchParams.set("cursor", cursor);
const page = await fetch(url, {
headers: { Authorization: `Bearer ${KEY}` },
signal: AbortSignal.timeout(30_000),
}).then((r) => r.json());
yield* page.data;
cursor = page.has_more ? page.next_cursor : null;
} while (cursor);
}
for await (const row of batchResults(batchId)) {
await db.contacts.update(row.email, { verdict: row.result, billed: row.billed });
}Errors and retries
A 429 and a 5xx are worth retrying. A 402 for credits and a 400 for a malformed address will fail the same way on every attempt, so stop.
async function withRetry(fn, attempts = 5) {
for (let i = 0; ; i++) {
try {
return await fn();
} catch (err) {
// insufficient_credits will fail the same way forever. Do not burn retries on it.
if (err.code === "insufficient_credits" || err.status === 400) throw err;
if (i >= attempts - 1) throw err;
// Jitter matters: a fleet retrying in lockstep just rebuilds the spike.
const wait = Math.min(2 ** i * 500, 8000) * (0.5 + Math.random());
await new Promise((r) => setTimeout(r, wait));
}
}
}
// AbortError from a timeout is not a bad address. At signup, fail open.
try {
return await withRetry(() => verify(email));
} catch (err) {
if (err.name === "TimeoutError" || err.name === "AbortError") {
return { email, result: "unknown", billed: false };
}
throw err;
}Node.js: common questions
Do I need axios?
No. Built-in fetch covers every call on this page, and one less dependency in a service that handles addresses is worth having.
Which Node versions work?
18 and up for built-in fetch. On 16, either use undici directly or stay on your existing HTTP client.
Where do I verify the webhook signature?
On the raw body, before any JSON parsing. Express needs express.raw({ type: 'application/json' }) on that route or the bytes are gone by the time you hash them.
Run this Node.js code today
100 free checks a month, no card, credits that never expire, and unknown results that cost nothing.