Put a synchronous third-party HTTP call in the middle of your registration handler and you have made someone else's uptime into your conversion rate. Email validation is worth doing anyway. The way to have both is to decide, before you write the call, what happens when it does not answer.
The rule
Validation is advisory. It may change what you show the user, and it may annotate the record you write. It may never be the reason a signup fails when the validator itself is the thing that broke.
That sounds obvious and is routinely violated, usually not by a deliberate decision but by a try block whose catch returns a 400.
Step 1: bound the call
Every validator call needs an explicit timeout, and it should be short. You are gating a form submission on it.
async function validateEmail(email) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 1500);
try {
const response = await fetch("https://nobounce.dev/v1/check", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.NOBOUNCE_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
signal: controller.signal,
});
if (!response.ok) return null;
return await response.json();
} catch {
// Timeout, DNS failure, or the service being unreachable. All the same to
// the caller: we have no opinion about this address.
return null;
} finally {
clearTimeout(timer);
}
}
null here means "no information", and the caller must handle it as a normal outcome rather than an exception. Never send the API key in a URL or a query parameter — it belongs in the Authorization header, loaded from a secret manager.
Note the error path also covers a non-2xx response. nobounce returns RFC 9457 problem+json with a plain-language fix field on every error, which is worth logging, but for the purposes of the signup handler an error is an error and the user proceeds.
Step 2: branch on the verdict, not on truthiness
There are four verdicts, and only one of them stops anything.
const result = await validateEmail(email);
// No answer at all. Proceed, and mark the record.
if (result === null) {
return createAccount({ email, emailVerdict: "unchecked", mxChecked: false });
}
// A correction is available. This is the case that pays for the integration.
if (result.verdict === "undeliverable" && result.suggestion) {
return respondWithSuggestion(result.suggestion);
}
// Definitively bad, nothing to suggest.
if (result.verdict === "undeliverable") {
return respondWithError(result.reason);
}
// deliverable, risky and unknown all proceed.
return createAccount({
email,
emailVerdict: result.verdict,
emailReason: result.reason,
mxChecked: result.checked.mx,
});
risky proceeds on purpose. It covers role accounts like admin@ and contato@, and disposable-domain addresses. Those are real addresses belonging to real users with real intentions, and rejecting them is a product decision you should make deliberately in your own code rather than inherit from a validator's default.
unknown proceeds because it means the check was degraded. Paired with checked.mx: false it is the contractual signal that the DNS lookup did not complete. A degraded check is never reported as deliverable, which is exactly what makes unknown trustworthy as a marker.
Step 3: store what was checked, not just the verdict
This is the step people skip, and the one that matters six months later.
ALTER TABLE users ADD COLUMN email_verdict TEXT;
ALTER TABLE users ADD COLUMN email_reason TEXT;
ALTER TABLE users ADD COLUMN email_mx_checked INTEGER;
Three columns. With them, "how many of our users have an address we actually verified against DNS" is a query. Without them, every address in your table looks equally trustworthy, and the ones that arrived during a validator outage are indistinguishable from the ones that passed a full check.
That query is what makes a later cleanup pass possible: re-check the rows where email_mx_checked = 0 when the validator is healthy, and you recover the coverage you lost during the incident. It also answers the awkward customer-support question directly, with a record rather than a shrug.
Step 4: make the suggestion the primary path
The correction is the reason to do this at all. undeliverable with a suggestion is not an error state, it is a conversation:
That address looks like a typo. Did you mean diego@gmail.com?
[ Use diego@gmail.com ] [ Keep diego@gmai.com ]
Two things about that UI. The suggestion is one click to accept, because friction here is the whole cost you are trying to avoid. And "keep what I typed" is a real button — confidence is a number between zero and one, not a certainty, and a user who insists on an unusual address should win. Rejecting outright converts a recoverable typo into an abandoned form.
What not to do
Do not validate on every keystroke. Validate on blur, or on submit. Live DNS checks are metered and a keystroke-triggered validator burns quota on prefixes of addresses that were never real.
Do not put validation in the critical path of a password reset or a login. Those addresses were validated at signup; re-validating them can only lock out an existing user whose provider changed something.
Do not treat risky as undeliverable. If you want to block disposable domains, branch on reason === "disposable" explicitly so the decision is visible in your code and can be changed without touching the validator.
Verify your integration handles the degraded case
The failure mode you cannot see in normal operation is the one worth testing. Point the client at an unroutable host, or set the timeout to one millisecond, and confirm a signup still completes with email_mx_checked = 0 recorded. If it returns a 400 to the user instead, the fail-open policy is aspirational rather than implemented.
For evaluating verdict shapes before you have a key, POST /demo/check needs no credentials. It resolves a frozen fixture corpus rather than live DNS, so it is a way to see every verdict and reason the API can produce — not a validator you can point production traffic at. Live checks require a paid key, from $1/mo.
The complete integration walkthrough, written for a coding agent to follow step by step, is at /integrate.md.