Email verification in Express
Express has one configuration detail that decides whether webhook signatures ever work, and it is worth getting right before anything else. express.json() consumes the request stream and leaves you a parsed object, so by the time your handler runs the bytes that were signed are gone.
Everything else is ordinary middleware. A signup check with a two-second budget, and a route that submits a batch and returns immediately.
Built-in fetch on Node 18+, with express.raw() scoped to the webhook route so the signed bytes survive.
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.
// 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://api.zapbounce.com/v1/verify", {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ email }),
signal: AbortSignal.timeout(2000), // someone is waiting on a form
});
if (!response.ok) throw new Error(`status ${response.status}`);
const { result, disposable } = await response.json();
req.emailVerdict = result;
// disposable is a boolean flag beside the result, never a value of it.
if (result === "invalid" || 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 batch rather than looping the single endpoint. We pace probes per receiving mail host, which protects the sending reputation your results depend on.
app.post("/lists/:id/verify", express.json(), async (req, res) => {
const emails = await db.contacts.pluckEmails(req.params.id);
const response = await fetch("https://api.zapbounce.com/v1/batches", {
method: "POST",
headers: {
Authorization: `Bearer ${KEY}`,
"Content-Type": "application/json",
// Stable per list: a double-click on the button must not bill twice.
"Idempotency-Key": `list-${req.params.id}`,
},
body: JSON.stringify({
name: `list-${req.params.id}`,
emails,
webhook_url: `${process.env.PUBLIC_URL}/webhooks/zapbounce`,
}),
signal: AbortSignal.timeout(30_000),
});
if (!response.ok) {
const { error } = await response.json();
return res.status(502).json({ error: error.code, request_id: error.request_id });
}
const batch = await response.json();
await db.lists.update(req.params.id, { batchId: batch.batch_id });
// 202: accepted, not finished. The webhook will say when it is.
res.status(202).json({ batch_id: batch.batch_id, unique: batch.unique });
});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.
import crypto from "node:crypto";
// express.raw() ONLY on this route. Mounting express.json() globally above it
// consumes the stream and the signature can never match again.
app.post(
"/webhooks/zapbounce",
express.raw({ type: "application/json" }),
async (req, res) => {
const header = req.get("ZapBounce-Signature") ?? "";
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) {
return res.status(401).send("stale signature");
}
const expected = crypto
.createHmac("sha256", process.env.ZAPBOUNCE_WEBHOOK_SECRET)
.update(`${parts.t}.`)
.update(req.body) // a Buffer, exactly as sent
.digest("hex");
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 ?? ""));
if (!ok) return res.status(401).send("bad signature");
const event = JSON.parse(req.body.toString("utf8"));
// Deliveries repeat. Insert-if-absent makes a replay harmless.
const fresh = await db.webhookEvents.insertIfAbsent(event.id);
if (fresh) await queue.add("import-results", { batchId: event.data.batch_id });
res.sendStatus(204); // acknowledge fast; import on the worker
},
);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 batch 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 that cost nothing.