Email verification in Express

Express has one detail that decides whether a signup check helps or hurts, and it is worth getting right before anything else. A fetch with no timeout holds the request open until the mail server answers, and the form hangs with it.

Everything else is ordinary middleware. A signup check with a two-second budget, and a route that submits a job and returns immediately, plus one that reports its status.

Built-in fetch on Node 18+, with AbortSignal.timeout on every call.

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
// middleware/verifyEmail.js
const KEY = process.env.ZAPBOUNCE_KEY;

export async function verifyEmail(req, res, next) {
  const { email } = req.body;

  try {
    const response = 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(2000),   // someone is waiting on a form
    });
    if (!response.ok) throw new Error(`status ${response.status}`);

    const { results } = await response.json();
    const { status, flags } = results[0];
    req.emailVerdict = status;

    // disposable is an entry in the flags array beside the status, never a value of it.
    if (status === "invalid" || flags.includes("disposable")) {
      return res.status(422).json({ error: "That address will not receive mail." });
    }
  } catch (err) {
    // Fail open. A verifier having a slow second must not cost you a signup.
    req.log.warn({ err }, "zapbounce unreachable");
    req.emailVerdict = "unknown";
  }
  next();
}

app.post("/signup", express.json(), verifyEmail, async (req, res) => {
  const user = await db.users.create({ email: req.body.email, verdict: req.emailVerdict });
  res.status(201).json({ id: user.id });
});

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" };

app.post("/lists/:id/verify", express.json(), async (req, res) => {
  const emails = await db.contacts.pluckEmails(req.params.id);

  const response = await fetch(`${API}/jobs`, {
    method: "POST",
    headers: {
      ...headers,
      // Stable per list: a double-click on the button must not bill twice.
      "Idempotency-Key": `list-${req.params.id}`,
    },
    body: JSON.stringify({ emails, source: "api" }),   // up to 50,000 per job
    signal: AbortSignal.timeout(30_000),
  });

  if (!response.ok) {
    const err = await response.json();
    return res.status(502).json({ error: err.code, requestId: err.requestId });
  }

  const job = await response.json();
  await db.lists.update(req.params.id, { jobId: job.id });
  // 202: accepted, not finished. Poll the status route below.
  res.status(202).json({ jobId: job.id });
});

// No webhook exists: the page (or a cron) polls this until the status is terminal.
app.get("/lists/:id/status", async (req, res) => {
  const { jobId } = await db.lists.get(req.params.id);
  const job = await fetch(`${API}/jobs/${jobId}`, { headers }).then((r) => r.json());
  res.json({
    status: job.status,   // pending | running | completed | partial | failed | cancelled
    valid: job.validEmails, invalid: job.invalidEmails,
    catchAll: job.catchAllEmails, unknown: job.otherEmails,
  });
  // When it is done, read rows from GET /jobs/{id}/emails?page=1&pageSize=100,
  // or stream GET /jobs/{id}/export as CSV.
});

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
// lib/zapbounce.js
export class ZapBounceError extends Error {
  constructor(body, status, windowSeconds) {
    super(body.message);
    Object.assign(this, { code: body.code, requestId: body.requestId, status, windowSeconds });
  }
}

export async function zbFetch(path, init = {}, attempts = 4) {
  for (let i = 0; ; i++) {
    const res = await fetch(`https://app.zapbounce.com/api/v1${path}`, {
      ...init,
      headers: { "X-API-Key": process.env.ZAPBOUNCE_KEY, "Content-Type": "application/json", ...init.headers },
      signal: AbortSignal.timeout(10_000),
    });
    if (res.ok) return res.json();

    // { statusCode, code, message, requestId }
    const err = new ZapBounceError(
      await res.json(), res.status, Number(res.headers.get("X-RateLimit-Window-Seconds") ?? 1),
    );
    // CREDITS_INSUFFICIENT is a 422 and fails the same way forever.
    if ((res.status !== 429 && res.status < 500) || i >= attempts - 1) throw err;

    // There is no Retry-After header. A 429 waits on the window length.
    const floor = res.status === 429 ? err.windowSeconds * 1000 : 500;
    await new Promise((r) => setTimeout(r, 2 ** i * floor * (0.5 + Math.random())));
  }
}

// Registered after the routes. Express 4 only gets here if the handler calls next(err).
app.use((err, req, res, next) => {
  if (err instanceof ZapBounceError) {
    const status = err.code === "CREDITS_INSUFFICIENT" ? 503 : 502;
    return res.status(status).json({ error: err.code, requestId: err.requestId });
  }
  next(err);
});

Express: common questions

Should signup block on unknown?

No. Unknown means a mail server would not answer us. Record it, let them in, and re-check later from a job.

Where does the key live?

Server-side environment only. A key reachable from the browser lets anyone spend your credits.

Do I need a queue for bulk?

You need the route to return before verification finishes. The jobs endpoint gives you that; a queue is for importing results afterwards.

Run this Express code today

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