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 statuses: valid, invalid, catch_all and unknown. Role and disposable arrive in the flags array beside the status, so test them separately. Unknown is the branch most code forgets, and its credit is refunded.

JavaScript
const KEY = process.env.ZAPBOUNCE_KEY;

async function verify(email) {
  const res = await fetch("https://app.zapbounce.com/api/v1/verify", {
    method: "POST",
    headers: {
      "X-API-Key": KEY,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ emails: [email] }),
    signal: AbortSignal.timeout(10_000), // fetch has no default. Set one.
  });

  if (!res.ok) {
    const err = await res.json(); // { statusCode, code, message, requestId }
    throw Object.assign(new Error(err.message), { code: err.code, status: res.status, requestId: err.requestId });
  }
  const { results } = await res.json();
  return results[0];
}

const r = await verify("ada@example.com");

switch (r.status) {
  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;     // credit refunded, not a verdict
  default:          await suppress(r.email);
}

if (r.flags.includes("disposable")) await suppress(r.email);

Verify a list

Submit a job rather than looping the single endpoint. We pace probes per receiving mail host, which protects the sending reputation your results depend on.

JavaScript
const API = "https://app.zapbounce.com/api/v1";
const headers = { "X-API-Key": KEY, "Content-Type": "application/json" };
const DONE = new Set(["completed", "partial", "failed", "cancelled"]);

async function submitJob(emails) {
  const res = await fetch(`${API}/jobs`, {
    method: "POST",
    headers: { ...headers, "Idempotency-Key": crypto.randomUUID() }, // a lost response must not bill twice
    body: JSON.stringify({ emails, source: "api" }), // up to 50,000 per job
    signal: AbortSignal.timeout(30_000),
  });
  return res.json();
}

/** There is no webhook: poll the job until it reaches a terminal status. */
async function waitForJob(id, everyMs = 5000) {
  for (;;) {
    const job = await fetch(`${API}/jobs/${id}`, { headers, signal: AbortSignal.timeout(30_000) })
      .then((r) => r.json());
    if (DONE.has(job.status)) return job;
    await new Promise((r) => setTimeout(r, everyMs));
  }
}

/** Async iterator over the pages, so callers can for-await without buffering. */
async function* jobRows(id) {
  let page = 1, totalPages = 1;
  do {
    const res = await fetch(`${API}/jobs/${id}/emails?page=${page}&pageSize=100`, {
      headers,
      signal: AbortSignal.timeout(30_000),
    }).then((r) => r.json());

    yield* res.items;
    totalPages = res.meta.totalPages;
  } while (page++ < totalPages);
}

const { id } = await submitJob(emails);
await waitForJob(id);
for await (const row of jobRows(id)) {
  await db.contacts.update(row.email, { verdict: row.status });
}

Errors and retries

A 429 and a 5xx are worth retrying. A 422 CREDITS_INSUFFICIENT 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) {
      // CREDITS_INSUFFICIENT (422) will fail the same way forever. Do not burn retries on it.
      if (err.code === "CREDITS_INSUFFICIENT" || err.status === 400) throw err;
      if (i >= attempts - 1) throw err;

      // RATE_LIMITED (429) carries no Retry-After. Back off from the window length instead.
      const windowS = err.windowSeconds ?? 1;
      // Jitter matters: a fleet retrying in lockstep just rebuilds the spike.
      const wait = Math.min(2 ** i * 500 * windowS, 60_000) * (0.5 + Math.random());
      await new Promise((r) => setTimeout(r, wait));
    }
  }
}
// In verify(), on a 429 add: windowSeconds: Number(res.headers.get("X-RateLimit-Window-Seconds"))

// 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, status: "unknown", reasonCode: "TIMEOUT", flags: [] };
  }
  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.

How do I get bulk results in Node.js?

Poll the job. Submit with POST /v1/jobs, call GET /v1/jobs/{id} until the status is completed, partial, failed or cancelled, then page through GET /v1/jobs/{id}/emails or stream GET /v1/jobs/{id}/export as CSV.

Run this Node.js code today

100 free checks a month, no card, credits that never expire, and unknown results refunded.