DEV Community

Damiana Teliti
Damiana Teliti

Posted on

How to verify emails at signup without blocking real users (Node.js)

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

Three design choices worth copying whatever tool you use:

  1. 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.
  2. 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.
  3. Store the flag. Save catch_all / unknown on 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 });
});
Enter fullscreen mode Exit fullscreen mode

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"]}'
Enter fullscreen mode Exit fullscreen mode

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)