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?
Top comments (0)