Most signup forms validate an email with a regex and call it done. That catches bob@@gmail but happily accepts jane@compnay.com, test@mailinator.com and ceo@a-domain-that-accepts-everything.com. A month later your welcome emails are bouncing and your sender reputation is paying for it.
Here's a small, practical pattern for checking addresses at signup without blocking real users. The code uses Node.js and the ListCheckup API (disclosure: I work on it), but the decision logic at the end applies to any verifier.
What a real check tells you
A proper verifier goes beyond syntax: it looks up the domain's MX records and asks the recipient's mail server whether the mailbox exists, without sending anything. You get back one of five answers:
| status | meaning | what to do at signup |
|---|---|---|
valid | mailbox exists | accept |
invalid | can't receive mail | reject, ask them to fix it |
risky | disposable or shared inbox | accept or warn, your call |
catch_all | domain accepts every address | accept, flag for later |
unknown | server wouldn't answer | accept, flag for later |
The important part is the last two. Never block a signup on catch_all or unknown. Plenty of real companies run catch-all domains, and plenty of real mail servers refuse to answer verification probes. Rejecting those turns away paying customers.
The handler
One request per signup, answered inline:
// verify-email.js (Node 18+, no dependencies) const API = "https://listcheckup.com/v1/verify"; export async function checkSignupEmail(email) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 4000); // never hold up signup try { const res = await fetch(API, { method: "POST", headers: { Authorization: `Bearer ${process.env.LISTCHECKUP_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ email }), signal: controller.signal, }); if (!res.ok) return { action: "accept", reason: "verifier_unavailable" }; const r = await res.json(); // { status, reason, confidence, ... } switch (r.status) { case "invalid": return { action: "reject", reason: r.reason }; case "risky": return { action: "warn", reason: r.reason }; case "catch_all": case "unknown": return { action: "accept", flag: true, reason: r.reason }; default: return { action: "accept", reason: r.reason }; } } catch { // timeout or network error: fail open, don't punish the user return { action: "accept", reason: "verifier_timeout" }; } finally { clearTimeout(timer); } } Three design choices worth copying whatever tool you use:
- Fail open. If the verifier is slow or down, accept the signup. A verification service should never be a single point of failure for your funnel.
- Hard timeout. Most checks answer in well under a second, but some mail servers are slow. Four seconds is a reasonable ceiling for a form.
- Store the flag. Save
catch_all/unknownon the user record. You'll want it later when deciding who gets your first newsletter.
Using it in a route
app.post("/signup", async (req, res) => { const { email } = req.body; const check = await checkSignupEmail(email); if (check.action === "reject") { return res.status(422).json({ error: "That address can't receive email. Could there be a typo?", }); } await db.users.insert({ email, email_unconfirmed: Boolean(check.flag), email_risky: check.action === "warn", }); res.status(201).json({ ok: true }); }); Note the error message: "can't receive email, could there be a typo?" is friendlier than "invalid email", because many invalid addresses at signup are typos, not bad actors.
What about lists you already have?
Checking one address at a time is for forms. For an existing list, send the whole thing in one request, get a price back before anything is charged, then start it:
curl -X POST "https://listcheckup.com/v1/lists" \ -H "Authorization: Bearer $LISTCHECKUP_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "old-subscribers", "emails": ["a@example.com", "b@example.com"]}' A webhook fires when it finishes, so you don't need to poll.
The pricing detail I'd check in any verifier
Since no verifier can confirm catch_all or unknown addresses, I think you shouldn't pay for them. ListCheckup doesn't charge for those two results, and there are 100 free checks a month, which is plenty for testing a signup flow. The full API reference is at listcheckup.com/api.
How do you handle email checks at signup today: regex only, double opt-in, or a verifier?
