Two people sign up for your product on the same afternoon. One types maria@gmial.com, never receives the confirmation email, and gives up. The other uses a Mailinator inbox to claim a second free trial. In your database both look the same: an account attached to an address that nobody will ever read.

A check at the form stops both. It can also go wrong: it can block real customers over a result it did not understand, or leave the submit button spinning for ten seconds while a slow mail server makes up its mind. Most of the value is in a handful of decisions about what to block, what to suggest and what to do when the answer is late.

This guide covers those decisions, then shows working Node and Python endpoints using the official RocketVerifier SDKs, and a no-code option for sites where you cannot touch the backend. If you want the mechanics behind each result first, read how email verification works.

What to check in the browser, and what needs the server

The browser can answer one question for free: is this string shaped like an email address? Use type="email" on the input and let the browser do it. The HTML standard defines a valid email address for this input type and calls its own rule a “willful violation” of RFC 5322, because the RFC grammar allows quoted strings and comments that no signup form should accept. That built-in rule is a better fit than most hand-written regexes, and it costs nothing.

Everything else needs a network: whether the domain exists, whether it publishes a mail server, whether the mailbox accepts mail, whether the domain belongs to a throwaway service. Those checks run on your server, through an API, for two reasons.

  • Your API key is a secret. Anything shipped to the browser can be read and reused.
  • The browser is not in charge. A bot does not run your JavaScript. It posts straight to your signup endpoint. The OWASP guidance is blunt about this: “Validate on the server even when the browser checks the same fields.” (OWASP Input Validation Cheat Sheet)

So the shape is: syntax in the browser for instant feedback, a call to your own endpoint when the field loses focus, and the same check again on the server when the form is submitted. The browser side looks like this:

<form id="signup" action="/signup" method="post">
  <label for="email">Email</label>
  <input id="email" name="email" type="email" autocomplete="email" required>
  <p id="email-hint" aria-live="polite"></p>
  <!-- password and other fields -->
  <button type="submit">Create account</button>
</form>

<script>
  const input = document.getElementById('email');
  const hint = document.getElementById('email-hint');

  async function checkEmail() {
    hint.replaceChildren();
    input.setCustomValidity('');
    const value = input.value;
    if (!value || !input.checkValidity()) return; // browser syntax check, no request

    let d;
    try {
      const res = await fetch('/api/check-email', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email: value }),
      });
      if (!res.ok) return; // rate limited or server error: stay quiet, the server decides at submit
      d = await res.json();
    } catch {
      return; // offline or slow: fail open
    }
    if (input.value !== value) return; // the user kept typing, this answer is stale

    if (d.suggestion) {
      const fix = document.createElement('button');
      fix.type = 'button';
      fix.textContent = d.suggestion;
      fix.onclick = () => { input.value = d.suggestion; checkEmail(); };
      hint.append('Did you mean ', fix, '? ');
    }
    if (d.action === 'block') {
      input.setCustomValidity(d.message); // the browser will refuse to submit
      hint.append(d.message);
    } else if (d.action === 'warn') {
      hint.append(d.message);
    }
  }

  input.addEventListener('blur', checkEmail);
</script>

Checking on blur rather than on submit matters more than it looks. The user moves on to the password field, and the one to three seconds a typical SMTP check takes are spent while they type. By the time they press the button, the server has the answer cached.

Offer a fix instead of an error

The most valuable field in the response is not status. It is did_you_mean.

RocketVerifier compares the domain against a curated list of misspelled provider domains (gmial.com, hotmial.com), bad endings like .con, and an edit-distance match against common providers. When it finds a likely correction it returns the full corrected address, for example maria@gmail.com for maria@gmial.com. In the Node SDK the field is didYouMean; in Python it is did_you_mean.

Three rules for using it well:

  1. Show it as a button, not a rewrite. Auto-correcting the field feels helpful until it changes a real but unusual domain. One click is cheap; a silently changed address is a support ticket.
  2. Read it before you branch on the status. A suggestion can arrive next to different results. A known typo domain may also be flagged as a likely trap, and a .con domain does not exist at all, so the same misspelling can come back as risky or undeliverable. Whatever the status, the suggestion is the most useful thing to show.
  3. When the result also blocks, show both. “Did you mean lee@company.com? We could not find that email domain.” tells the user what went wrong and how to fix it in one line.
  1. 1 Typo

    Suggest

    Email

    maria@gmial.com

    Did you mean maria@gmail.com?

    The submit button stays enabled.

    API says (any status)

    did_you_mean: "maria@gmail.com"

    Form does: Offer the fix as a button. Never rewrite the field silently, and read did_you_mean before you look at the status.

  2. 2 Disposable

    Block

    Email

    freebie42@mailinator.com

    Temporary inboxes cannot be used. Use an address you will keep.

    The submit button is disabled until the address changes.

    API says undeliverable/ disposable

    domain.disposable: true

    Form does: Stop the submit and say why. A generic "invalid email" makes people retype the same address.

  3. 3 Role account

    Warn

    Email

    info@lumenlabs.io

    This looks like a shared inbox. You can continue, but a personal address makes account recovery easier.

    The submit button stays enabled.

    API says risky/ role_based

    account.role: true

    Form does: Let it through. The mailbox is real; some teams sign up with a shared address on purpose.

One field, three outcomes. The status on the typo card is left open on purpose: the suggestion is what the form acts on, whatever status comes with it.

Which results to block, warn on, or allow

The API returns one of four statuses (deliverable, undeliverable, risky, unknown) and a sub_status with the reason. A form only needs three behaviours. The table maps each result to one of them.

Result Form behaviour Why
Any result with did_you_mean Suggest the fix, one click to apply Recovers real users who made a typo
undeliverable/ invalid_syntax Block Not a usable address
undeliverable/ invalid_domain Block The domain does not exist
undeliverable/ does_not_accept_mail Block Null MX or no mail route, nothing can arrive
undeliverable/ mailbox_not_found undeliverable/ account_disabled Block The mail server said no to this mailbox
undeliverable/ disposable Block Works for an hour, then disappears
risky/ toxic risky/ possible_trap Block, or ask for another address Placeholders like test@test.com and trap patterns. Our call, not a rule
risky/ role_based Warn, allow Real shared inbox; some teams sign up this way on purpose
risky/ catch_all Allow; warn only if catch_all_verdict is avoid The user cannot fix their company’s mail setup
risky/ mailbox_full Allow, flag for review The mailbox exists; it may clear later
unknown/ smtp_timeout and every other unknown Allow, verify again later No answer is not a bad answer
deliverable/ none deliverable/ alias Allow Confirmed by the mail server
Timeout or API error on your side Allow, verify again later Fail open (next section)

The block list is short on purpose. Each entry is an address that cannot receive mail, or one that will stop receiving it within hours. If you want to see which domains count as disposable, try a few in the free disposable email checker, and the glossary entry explains why they cost more than a missing confirmation email.

Role accounts and catch-alls are different. A billing@ address at a real company is a real inbox, and blocking it turns away a customer who chose it deliberately. A catch-all domain is a property of the company’s mail server, and telling the user “we could not confirm your mailbox” asks them to fix something they do not control. Allow both, tag the account, and handle them in your sending rules instead. The role-based address entry covers why marketing sends treat them differently.

Write messages that say what to do

The block message is the only part of this the user reads, so make it specific. “Invalid email” makes people retype the same address. These work better:

  • invalid_domain: “We could not find that email domain. Check the spelling after the @.”
  • mailbox_not_found: “That mailbox does not exist. Check the spelling before the @.”
  • disposable: “Temporary inboxes cannot be used. Please use an address you will keep.”
  • role_based (warning): “This looks like a shared inbox. You can continue, but a personal address makes account recovery easier.”

Put the message in an element with aria-live="polite", as in the snippet above, so screen readers announce it when it appears.

Timeouts: set a budget and fail open

Syntax, disposable and DNS checks finish in milliseconds. The SMTP step depends on the receiving server and usually takes one to three seconds. Some servers are slower, and a greylisting server will not give a real answer for minutes. Your user will not wait that long, and should not have to.

The pattern:

  1. Pick a budget. We use 4 seconds for a signup path. That covers most SMTP checks and is short enough that a spinner on the submit button still feels like progress. It is a judgment call; measure your own form.
  2. Turn off retries in the form path. Both SDKs retry by default, which suits batch work and hurts a waiting user. The Node SDK, for example, waits for the Retry-After interval before retrying a 429.
  3. Fail open. If the budget runs out, the network fails, or the API says you are rate limited or out of credits, accept the signup. A verification outage should never become a signup outage.
  4. Verify again after signup. Queue a background check without the tight budget. If the address turns out to be undeliverable, flag the account and ask the user to update their email next time they log in. Do not delete the account.

An unknown result gets the same treatment as a timeout: allow, then check again later. RocketVerifier does not charge for unknown results, so the re-check costs nothing until it returns a real answer.

Node.js: an Express endpoint with the official SDK

This uses @rocketverifier/sdk. It exposes a checkEmail function that both the blur endpoint and the signup handler call, so a user who submits while the blur check is still running shares one API call instead of paying for two.

// server.js  (npm install express express-rate-limit @rocketverifier/sdk)
import express from 'express';
import rateLimit from 'express-rate-limit';
import RocketVerifier, { ValidationError } from '@rocketverifier/sdk';

const app = express();
app.set('trust proxy', 1); // behind a load balancer, so req.ip is the visitor's IP
app.use(express.json());

// A client just for the signup path: 4 second budget, no retries.
// The SDK defaults (30 s, 3 retries) suit batch work, not a waiting user.
const rv = new RocketVerifier(process.env.ROCKETVERIFIER_API_KEY, {
  timeout: 4000,
  maxRetries: 0,
});

const BLOCK = {
  invalid_syntax: 'This address is missing something. Check the @ and the part after it.',
  invalid_domain: 'We could not find that email domain. Check the spelling after the @.',
  does_not_accept_mail: 'That domain does not receive email. Please use another address.',
  mailbox_not_found: 'That mailbox does not exist. Check the spelling before the @.',
  account_disabled: 'That mailbox has been switched off. Please use another address.',
  disposable: 'Temporary inboxes cannot be used. Please use an address you will keep.',
  toxic: 'Please enter the email address you actually use.',
  possible_trap: 'Please check this address for typos.',
};

function decide(r) {
  const suggestion = r.didYouMean ?? null;
  const blockMessage = BLOCK[r.subStatus];
  if (blockMessage) return { action: 'block', message: blockMessage, suggestion };
  if (suggestion) return { action: 'suggest', suggestion };
  if (r.subStatus === 'role_based') {
    return { action: 'warn', message: 'This looks like a shared inbox. You can continue, but a personal address makes account recovery easier.' };
  }
  if (r.subStatus === 'catch_all' && r.catchAllVerdict === 'avoid') {
    return { action: 'warn', message: 'We could not confirm this mailbox. Please double-check it.' };
  }
  // Deliverable, the remaining risky results, and every unknown get in.
  return { action: 'allow', recheck: r.status === 'unknown' };
}

// In-flight and finished checks, keyed by address. One process only:
// move it to Redis with a TTL (a day is plenty) when you run several servers.
const cache = new Map();

export function checkEmail(raw) {
  const email = String(raw ?? '').trim();
  const key = email.toLowerCase();
  if (!cache.has(key)) {
    const pending = rv.verify(email)
      .then(decide)
      .catch((err) => {
        if (err instanceof ValidationError) {
          return { action: 'block', message: BLOCK.invalid_syntax };
        }
        // err.code is 'timeout', 'network_error', 'rate_limit_exceeded',
        // 'insufficient_credits' and so on. All of them fail open.
        console.warn('email check skipped:', err.code ?? err.message);
        return { action: 'allow', recheck: true };
      })
      .then((decision) => {
        if (decision.recheck) cache.delete(key); // never cache an answer we did not get
        return decision;
      });
    cache.set(key, pending);
  }
  return cache.get(key);
}

app.post('/api/check-email', rateLimit({ windowMs: 60_000, limit: 10 }), async (req, res) => {
  const { recheck, ...decision } = await checkEmail(req.body?.email);
  res.json(decision);
});

app.post('/signup', async (req, res) => {
  const decision = await checkEmail(req.body?.email);
  if (decision.action === 'block') return res.status(422).json(decision);

  const user = await createUser(req.body);            // your code
  if (decision.recheck) await queueReverify(user.id); // your job queue, run without the 4 s budget
  res.status(201).json({ ok: true });
});

app.listen(3000);

A few details that are easy to miss:

  • The SDK’s timeout covers the whole request, including reading the body. When it fires, verify rejects with a RocketVerifierError whose code is 'timeout'.
  • ValidationError is thrown before any request when the input has no @, so it is safe to treat as a block.
  • The result fields are camelCase in Node: status, subStatus, didYouMean, catchAllVerdict. The REST response uses sub_status, did_you_mean and catch_all_verdict, documented in the verify endpoint reference.

Python: FastAPI with the async client

The Python SDK ships an AsyncRocketVerifier with the same methods as the sync client, which fits FastAPI. The fields are snake_case: status, sub_status, did_you_mean, catch_all_verdict.

# main.py  (pip install "rocketverifier[async]" fastapi)
import asyncio
import logging
import os

from fastapi import BackgroundTasks, FastAPI, HTTPException
from pydantic import BaseModel
from rocketverifier import AsyncRocketVerifier, RocketVerifierError, ValidationError

log = logging.getLogger(__name__)
API_KEY = os.environ["ROCKETVERIFIER_API_KEY"]
BUDGET_SECONDS = 4.0

app = FastAPI()
# The form path: no retries. asyncio.wait_for below is the hard limit.
rv = AsyncRocketVerifier(API_KEY, timeout=BUDGET_SECONDS, max_retries=0)

BLOCK = {
    "invalid_syntax": "This address is missing something. Check the @ and the part after it.",
    "invalid_domain": "We could not find that email domain. Check the spelling after the @.",
    "does_not_accept_mail": "That domain does not receive email. Please use another address.",
    "mailbox_not_found": "That mailbox does not exist. Check the spelling before the @.",
    "account_disabled": "That mailbox has been switched off. Please use another address.",
    "disposable": "Temporary inboxes cannot be used. Please use an address you will keep.",
    "toxic": "Please enter the email address you actually use.",
    "possible_trap": "Please check this address for typos.",
}


def decide(r) -> dict:
    if r.sub_status in BLOCK:
        return {"action": "block", "message": BLOCK[r.sub_status], "suggestion": r.did_you_mean}
    if r.did_you_mean:
        return {"action": "suggest", "suggestion": r.did_you_mean}
    if r.sub_status == "role_based":
        return {"action": "warn", "message": "This looks like a shared inbox."}
    if r.sub_status == "catch_all" and r.catch_all_verdict == "avoid":
        return {"action": "warn", "message": "We could not confirm this mailbox. Please double-check it."}
    return {"action": "allow", "recheck": r.status == "unknown"}


async def check_email(email: str) -> dict:
    try:
        result = await asyncio.wait_for(rv.verify(email.strip()), timeout=BUDGET_SECONDS)
    except ValidationError:  # subclass of RocketVerifierError, so catch it first
        return {"action": "block", "message": BLOCK["invalid_syntax"]}
    except (asyncio.TimeoutError, RocketVerifierError) as exc:
        # Over budget, network error, rate limited or out of credits: fail open.
        log.warning("email check skipped: %r", exc)
        return {"action": "allow", "recheck": True}
    return decide(result)


async def reverify(user_id: str, email: str) -> None:
    # SDK defaults here: 30 s timeout and 3 retries. Nobody is waiting.
    async with AsyncRocketVerifier(API_KEY) as patient:
        result = await patient.verify(email)
    if result.status == "undeliverable":
        await flag_unreachable_email(user_id, result.sub_status)  # your code


class SignupIn(BaseModel):
    email: str
    password: str


@app.post("/signup")
async def signup(body: SignupIn, tasks: BackgroundTasks):
    decision = await check_email(body.email)
    if decision["action"] == "block":
        raise HTTPException(status_code=422, detail=decision)

    user_id = await create_user(body)  # your code
    if decision.get("recheck"):
        tasks.add_task(reverify, user_id, body.email)
    return {"ok": True}

Why asyncio.wait_for on top of the client timeout: the HTTP client’s timeout applies to each phase (connecting, then waiting to read), so the worst case is longer than the number you passed. wait_for puts one ceiling over the whole call.

BackgroundTasks runs reverify right after the response is sent, which is fine for a first version. In production, put it on your job queue with a delay of several minutes, so a greylisting server has time to accept the retry.

The Python client also accepts a server-side deadline, rv.verify(email, timeout=5), between 5 and 60 seconds. When it expires the API itself returns an unknown result, which is not charged. It is useful in background jobs; for a form, the client-side budget above is the one the user feels.

Testing without spending credits

Create a sandbox key in the dashboard. Keys that start with rv_test_ never charge credits and only accept addresses at @sandbox.rocketverifier.com; any other address gets 403 sandbox_key_restricted. Each test address returns a fixed result:

  • deliverable@sandbox.rocketverifier.com: the allow path
  • undeliverable@sandbox.rocketverifier.com: the block path, as mailbox_not_found
  • disposable@sandbox.rocketverifier.com: the disposable block message
  • risky@sandbox.rocketverifier.com: a catch-all result
  • unknown@sandbox.rocketverifier.com: the allow-and-recheck path

Use these in CI and staging. Two paths need other tricks. Sandbox results never include a did_you_mean, so test the typo flow by hand with a live key and a few credits. To test fail-open, point the client’s base URL (baseUrl in Node, base_url in Python) at an address that never answers, and confirm the signup still succeeds.

Abuse: your check endpoint spends your money

/api/check-email is a public endpoint that runs a paid lookup on any address it receives. Left open, it is a free email verifier for anyone who finds it, billed to you. Three controls cover it.

Rate limit per IP. The Express example allows 10 checks per IP per minute. A person filling in a form needs two or three; pick a limit that leaves room for typos and corrections, and remember that offices and mobile carriers put many people behind one IP. When the limit trips, the browser stays quiet and the server still checks at submit.

Cache repeat checks. The same address checked twice should cost one credit. Cache definitive answers by lowercased address for about a day. Do not cache unknowns or errors: the next attempt might get a real answer.

Put a CAPTCHA on the submit, not the blur check. Cloudflare Turnstile is a low-friction option. Its documentation is explicit that the server must validate each token by calling the siteverify API, that a token is valid for 300 seconds, and that it can be validated once (Cloudflare Turnstile docs). Single use means it belongs on the final submit. Validate it before you spend a verification credit:

// Turnstile adds its token to the form as cf-turnstile-response.
async function isHuman(token, ip) {
  const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
    method: 'POST',
    body: new URLSearchParams({
      secret: process.env.TURNSTILE_SECRET_KEY,
      response: token ?? '',
      remoteip: ip,
    }),
  });
  const outcome = await res.json();
  return outcome.success === true;
}

There is one attack email verification cannot stop at all. In subscription bombing, bots sign up real people’s addresses through open forms so that the confirmation emails flood the victim’s inbox. Spamhaus documented a wave of this in 2016, including account signups on WordPress sites, and concluded that confirmed opt-in alone was not enough, since the confirmation emails were the flood. Its advice was CAPTCHA plus confirmed opt-in (Spamhaus). The victim’s address is real and deliverable, so verification waves it through. Only bot protection stops this.

Why double opt-in still matters

Verification answers “will this mailbox accept mail?” Double opt-in answers “does the person who typed it control it?” They solve different problems, and a signup flow that sends marketing email wants both.

Google’s sender guidelines say it directly: “Confirm each recipient’s email address before subscribing them.” (Google email sender guidelines) Verification does not satisfy that. Someone can type a colleague’s real, deliverable address, and only a confirmation link proves who owns it.

The two work well in sequence. Verification first, so you never send a confirmation email that will bounce, which protects your bounce rate on every signup. The confirmation link second, so your list only holds people who asked to be on it. For product accounts the confirmation step can be lighter, such as a “verify your email” banner instead of a locked account, but the link is still the only proof of ownership.

The no-code route: RocketShield

If your form lives in WordPress, Webflow, Shopify or a page builder where you cannot add a backend endpoint, RocketShield does the browser half for you. It is one script tag:

<script>
  var rocketShieldConfig = {
    apikey: "rs_your_public_key",
  };
</script>
<script src="https://app.rocketverifier.com/widget/v2.js"></script>

The rs_ key is public by design. It can only run shield checks, and you lock it to your own domains in the dashboard. Email inputs are detected automatically, including forms that load after the page; add data-rocketshield to check any other field.

The rules live in the dashboard. Undeliverable, disposable and toxic addresses are blocked by default; role-based addresses, free providers, catch-all domains and unknown results are optional. A single site can make the rules stricter, never looser, with overrides in the config:

<script>
  var rocketShieldConfig = {
    apikey: "rs_your_public_key",
    blockRoleBased: true,
    blockedMessage: "Please use your own work email address.",
  };
</script>
<script src="https://app.rocketverifier.com/widget/v2.js"></script>

It handles the abuse basics described above: checks wait until the visitor stops typing, repeat checks on a page are answered from the browser cache, and a per-IP rate limit (25 checks per hour by default, adjustable from 1 to 100) stops bots from draining credits. Each check uses 1 credit and unknown results are free. The full option list is in the widget docs.

RocketShield still runs in the browser, so the rule from the start of this guide applies: a bot that posts straight to your endpoint never meets it. For forms where it matters, such as free trials, account creation and anything that triggers paid work, pair it with the server-side call to the Email Verification API before you create the record.

Try it on your own signup form

Use a sandbox key to wire up every path for free, then switch to a live key. Unknown results are never charged.

Start with 100 free credits