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