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.

JavaScript
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.

JavaScript
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.

JavaScript
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.