uCheckeruChecker
Mark AvrilovPublished updated 16 min read

Email validation in signup forms: from regex to API

Most bad addresses enter a mailing list through a form: a newsletter box, a registration page, a checkout step. Each one costs you later. Your ESP bills per send, bounces pile up, and domain reputation slides. Stopping junk at the form is far easier than scrubbing the list after the fact, and this guide walks through how to do it.


Three validation layers: fast to accurate

One regex will not do the job. Reliable validation in a form has three layers, and each catches a different kind of mistake.

Layer 1: client-side (regex). Instant, no network round trip. It catches the obvious: an empty field, a missing @, spaces, a double dot in the domain. It knows nothing about whether the mailbox exists, but the user gets feedback right away.

Layer 2: server-side (business logic). Checks on your backend when the form is submitted: address normalization, duplicate detection, a lookup against your own domain blocklist.

Layer 3: an external API. A validation service looks up the domain's MX records, asks the receiving mail server over SMTP whether the mailbox exists, and compares the domain against known disposable-mail patterns. This layer is the most accurate and also the slowest. At uChecker a single check takes anywhere from a few seconds to several minutes, with domains running strict anti-spam policies at the long end. So the form does not sit and wait for it; more on that below.

Each layer handles what the one before it cannot. Regex stops typos before any request leaves the browser, the server drops duplicates and known junk domains, and the API checks whatever survived both.

What real-time email verification means

Real-time email verification is checking an address at the moment someone types it in. One address goes out for checking as soon as it is entered, rather than the whole list once a quarter.

Signup and subscription forms are the obvious home for it. Two other places benefit just as much. At checkout, the buyer leaves an email for the receipt and shipping updates, and a typo there means the order confirmation never arrives. In a CRM or an internal form where staff type customer addresses by hand, you catch the mistake while it is being entered instead of after the first bounce.

Batch verification is a different tool. It takes an existing list of thousands of addresses and cleans out what has piled up. Checking at entry keeps new junk out. You want both, because an address that was fine on signup day can be shut down later.

"Real time" is a loose term here. Syntax is checked instantly. Finding out whether a mailbox exists means talking to the recipient's mail server, and that server may answer in one second or in sixty. Hence the setup below, where the form waits a few seconds at most and the backend finishes the job.

There is a privacy side to this too. The address leaves your site for a third-party service, which counts as processing personal data, so mention the check in your privacy policy.

Client-side validation: regex and UX

Your regex does not need to be perfect. RFC 5322 allows addresses like "quoted string"@example.com, but real forms almost never see them. A simple pattern that holds up in practice:

const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;

function validateEmailFormat(email) {
  if (!email) return 'Enter your email';
  if (!EMAIL_RE.test(email.trim())) return 'Invalid email format';
  return null; // no error
}

It skips the RFC edge cases on purpose and checks the minimum: something before the @, something after, a dot, and a top-level domain of at least two characters. Good enough for a first filter.

A common mistake is flagging an error while the user is still typing. user@ is invalid, but the person has not finished yet, and a red message at that moment is just friction. Validate on blur (when the user leaves the field) or with a 300-500 ms debounce.

const emailInput = document.getElementById('email');
const errorEl = document.getElementById('email-error');

emailInput.addEventListener('blur', () => {
  const error = validateEmailFormat(emailInput.value);
  errorEl.textContent = error || '';
  emailInput.classList.toggle('border-red-400', !!error);
});

React and Vue follow the same rule: validate on blur, not on every keystroke. The error shows up when the user is done typing and disappears as soon as they start fixing it.

Server-side validation: normalization and deduplication

Anyone can skip your client-side checks by opening DevTools and posting to the endpoint directly. Server-side validation is required even when the frontend is airtight.

Normalize on the server. People type John.Doe@Gmail.Com without a thought for case. Domains are case-insensitive. RFC 5321 technically lets the mailbox name be case-sensitive but advises against relying on it, and mail providers don't. Personal gmail.com mailboxes have their own rules: Google ignores dots in the username and treats anything after a plus sign as a label on the same inbox. Skip normalization and one person can sign up twice.

// A key for finding duplicates. Send mail to the address
// exactly as the user typed it.
function dedupKey(email) {
  let [local, domain] = email.trim().toLowerCase().split('@');

  // gmail.com only: on Google Workspace and other domains
  // dots in the username are significant
  if (domain === 'gmail.com') {
    local = local.split('+')[0].replace(/\./g, '');
  }

  return local + '@' + domain;
}

// dedupKey('John.Doe+spam@Gmail.Com')
// => 'johndoe@gmail.com'

The server is also a good place to compare the domain against an open list of disposable-mail services. Those lists get updated, but new throwaway domains keep appearing, and some will always be newer than the last update. That gap is what the third layer covers.

Verification through the uChecker API

An external API does what a browser can't: it reads MX records, talks SMTP to the receiving server, and compares the domain against disposable-mail patterns and blocklists. Call it from your backend. The API key travels in the x-api-key header, and in client code anyone with the Network tab open could read it.

Here is how POST /api/v1/validate/single behaves. The address first gets a syntax check. If the format is clearly wrong, the reply comes back immediately with "status": "invalid" and no credit is charged. Otherwise the address is queued, one credit is charged, and the reply carries a task_id. You collect the verdict in one of two ways. Either poll GET /api/v1/tasks/{taskId} and then read /results, or pass a webhook_url and uChecker will POST to it when the check is done.

A form works best with both. The backend waits a few seconds, polling the task every 5 seconds as the documentation suggests. If the verdict arrives in time, the form shows it right away; if not, the signup goes through, the contact is flagged as unverified, and the webhook delivers the result later. An Express example:

const UC = 'https://api.uchecker.net/api/v1';
const headers = {
  'Content-Type': 'application/json',
  'x-api-key': process.env.UCHECKER_API_KEY,
};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// POST /api/validate-email
app.post('/api/validate-email', async (req, res) => {
  const email = String(req.body.email || '').trim();

  try {
    const task = await fetch(`${UC}/validate/single`, {
      method: 'POST',
      headers,
      body: JSON.stringify({
        email,
        webhook_url: 'https://example.com/hooks/uchecker',
      }),
      signal: AbortSignal.timeout(5000),
    }).then((r) => r.json());

    // Failed syntax: instant reply, no credit charged
    if (task.status === 'invalid') return res.json({ status: 'invalid' });
    // 401, 403 (out of credits) and other errors: let it through
    if (!task.task_id) return res.json({ status: 'pending' });

    // Two polls 5 s apart; after that the webhook takes over
    for (let i = 0; i < 2; i++) {
      await sleep(5000);
      const state = await fetch(`${UC}/tasks/${task.task_id}`, { headers })
        .then((r) => r.json());
      if (state.status === 'failed') break;
      if (state.status !== 'completed') continue;

      const { data } = await fetch(
        `${UC}/tasks/${task.task_id}/results`, { headers }
      ).then((r) => r.json());
      // validation_result: good | bad | unknown
      return res.json({ status: data[0].validation_result });
    }
    return res.json({ status: 'pending' });
  } catch (err) {
    // API unreachable: let it through, verify later
    console.error('Validation API error:', err.message);
    return res.json({ status: 'pending' });
  }
});

Note the pending branches. If the validation API is briefly down or your credits run out, the form must keep working. Letting a questionable address through and checking it later beats showing a server error on the signup page.

The webhook carries a summary of the task (task_id, status and good, bad, unknown counts) rather than the per-address verdict, so your handler fetches the results in a separate call. The webhook URL has to be reachable from the internet and answer 200; uChecker refuses to post to private addresses.

// POST /hooks/uchecker: uChecker calls this when the task is finished
app.post('/hooks/uchecker', async (req, res) => {
  res.sendStatus(200);
  const { task_id, status } = req.body;
  if (status !== 'completed') return;

  const { data } = await fetch(`${UC}/tasks/${task_id}/results`, { headers })
    .then((r) => r.json());
  for (const row of data) {
    // your function: store the verdict on the contact record
    await markContact(row.email, row.validation_result, row.result);
  }
});

Every method, the error codes and a request sandbox are in the API documentation.

Frontend: calling the backend and handling the response

The client has two jobs: fire the request at the right moment, and show the result without getting in the way. Three things cover it: validation on blur, a loading state, and plain messages.

const emailField = document.getElementById('email');
const statusEl = document.getElementById('email-status');
let controller = null; // AbortController to cancel the previous request

emailField.addEventListener('blur', () => {
  const email = emailField.value.trim();
  const formatError = validateEmailFormat(email);

  if (formatError) {
    showStatus(formatError, 'error');
    return;
  }

  // Show a checking indicator
  showStatus('Checking...', 'loading');

  // Cancel the previous request, if any
  if (controller) controller.abort();
  controller = new AbortController();

  fetch('/api/validate-email', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email }),
    signal: controller.signal,
  })
    .then(r => r.json())
    .then(data => {
      if (data.status === 'bad') {
        showStatus('Mail to this address will not arrive. Any typos?', 'error');
      } else if (data.status === 'invalid') {
        showStatus('Invalid email format', 'error');
      } else {
        // good, unknown, pending: do not hold up the signup
        showStatus('', 'ok');
      }
    })
    .catch(err => {
      if (err.name !== 'AbortError') {
        showStatus('', 'ok'); // never block on a network error
      }
    });
});

function showStatus(message, type) {
  statusEl.textContent = message;
  statusEl.className = {
    error: 'text-red-600 text-sm mt-1',
    loading: 'text-gray-400 text-sm mt-1',
    ok: '',
  }[type] || '';
}

The AbortController matters here. When the user hops between fields or edits the address, the previous request is cancelled. Without it, responses can land out of order and the status ends up describing a value the user already changed.

React example

Same logic in React, wrapped in a hook. An email field with validation:

import { useState, useRef, useCallback } from 'react';

function useEmailValidation() {
  const [status, setStatus] = useState('idle');
  // idle | checking | valid | invalid
  const [message, setMessage] = useState('');
  const controllerRef = useRef(null);

  const validate = useCallback(async (email) => {
    // Format
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(email)) {
      setStatus('invalid');
      setMessage('Invalid email format');
      return;
    }

    setStatus('checking');
    setMessage('');

    if (controllerRef.current) controllerRef.current.abort();
    controllerRef.current = new AbortController();

    try {
      const res = await fetch('/api/validate-email', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email }),
        signal: controllerRef.current.signal,
      });
      const data = await res.json();

      if (data.status === 'bad' || data.status === 'invalid') {
        setStatus('invalid');
        setMessage('Mail to this address will not arrive');
      } else {
        setStatus('valid'); // good, unknown, pending
        setMessage('');
      }
    } catch (err) {
      if (err.name !== 'AbortError') {
        setStatus('valid'); // network failed: do not hold the form
        setMessage('');
      }
    }
  }, []);

  return { status, message, validate };
}

Using it in a form component:

function SignupForm() {
  const { status, message, validate } = useEmailValidation();
  const [email, setEmail] = useState('');

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        value={email}
        onChange={e => setEmail(e.target.value)}
        onBlur={() => email && validate(email)}
        className={status === 'invalid' ? 'border-red-400' : ''}
      />
      {status === 'checking' && <span>Checking...</span>}
      {message && <span className="text-red-600">{message}</span>}

      <button type="submit" disabled={status === 'invalid'}>
        Sign up
      </button>
    </form>
  );
}

The button is disabled only on a clear error. While the check is still running the user can submit, since the backend will accept the address and finish the verification on its own.

UX patterns: what works and what annoys people

Correct code with careless UX does more harm than good. A few rules worth keeping in mind.

Do not block input during verification. The user filled in the email and moved on to the password, and the check runs in the background. A spinner over the whole form makes them sit and wait. Let them keep going; the result can appear when it is ready.

Specific error messages. "Invalid email" tells the user nothing. "The domain gmail.co does not exist. Did you mean gmail.com?" actually helps. The more precise the message, the better the odds they fix the address instead of leaving.

Suggestions for common typos. Domains like gmial.com, yahooo.com and hotmal.com turn up on any form with real traffic. Levenshtein distance against a short list of popular domains handles most of them. There is a second reason to bother: the Amazon SES FAQ notes that some spam traps sit on domains built from a typo in a well-known mail provider's name.

const COMMON_DOMAINS = [
  'gmail.com', 'yahoo.com', 'hotmail.com', 'outlook.com',
  'icloud.com', 'aol.com', 'proton.me', 'live.com',
];

function suggestDomain(inputDomain) {
  const domain = inputDomain.toLowerCase();
  if (COMMON_DOMAINS.includes(domain)) return null;

  for (const known of COMMON_DOMAINS) {
    if (levenshtein(domain, known) <= 2) {
      return known; // "gmial.com" => suggest "gmail.com"
    }
  }
  return null;
}

Visual status, not just text. A green checkmark next to the field when the address is fine, a red border on error, a gray spinner while checking.

Do not reject unknown. This status means the check could not reach a verdict: the domain's MX servers did not answer, the domain has no MX records, it looks like a disposable service, or it sits on a blocklist. Or the domain is catch-all and accepts mail for any address, so its "yes" proves nothing about this mailbox. Real corporate mailboxes land here too. Accept the address with a flag on the record and decide later, for example based on whether the person confirmed the subscription.

Error handling and edge cases

The API can return an error, the network can drop, a timeout can fire. Handle each case or the form breaks in production.

Request timeout. The backend above already waits about ten seconds for a verdict, so give the client timeout some room on top of that. If it runs out, let the user through and the address gets verified in the background.

async function validateWithTimeout(email, timeoutMs = 15000) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);

  try {
    const res = await fetch('/api/validate-email', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email }),
      signal: controller.signal,
    });
    clearTimeout(timer);
    return await res.json();
  } catch (err) {
    clearTimeout(timer);
    // Timeout or network error: let it through
    return { status: 'pending' };
  }
}

Caching. Every validate/single call with a well-formed address costs a credit. If someone enters the same address twice, there is no reason to pay for it twice.

const validationCache = new Map();

async function cachedValidate(email) {
  const normalized = email.trim().toLowerCase();

  if (validationCache.has(normalized)) {
    return validationCache.get(normalized);
  }

  const result = await validateWithTimeout(normalized);
  // do not cache pending: the real answer is still coming
  if (result.status !== 'pending') validationCache.set(normalized, result);

  // Cap the cache size
  if (validationCache.size > 500) {
    const firstKey = validationCache.keys().next().value;
    validationCache.delete(firstKey);
  }

  return result;
}

Retries. The uChecker API has no rate limits, so you will not see a 429 from it. On a 5xx or a network error, one retry after a second or two is plenty; nobody waits longer on a signup page. A 403 is not worth retrying at all, because it means the account is out of credits. Let the address through and log it.

Fallback when the API is down. The form should keep running on the first two layers. The address goes into the database unverified, and you can run it through the batch method later. A lost signup costs more than one questionable email.

Which API response fields to use

A form needs four fields, and they arrive in different responses.

status in the validate/single reply. invalid comes back at once and means a syntax error, which you can show without waiting. queued means the address went out for checking.

task_id. The task number. You poll and fetch results with it, and the webhook sends it back to you.

validation_result (good / bad / unknown) in the task results. The main verdict. For bad, show an error: the mailbox does not exist, is disabled, or the domain does not accept mail. For unknown, accept with a flag. Until the address is checked the field is null.

result. The checking server's reply as an SMTP line, such as 550 5.1.2 <risk> No MX records found. It is free text, so never compare it for exact equality. Users don't need to see it, but in your logs it explains why an address was rejected.

When to call the API: three strategies

Which one fits depends on your form's conversion rate and traffic.

On blur. The check starts when the user leaves the email field. While they fill in the rest, the answer has time to arrive, and by the time they click "Sign up" it is often already there. The cost is credits: every edit to the address means a new request.

On submit. The check runs only when the form is sent, so the API is called once no matter how many times the address was edited. The downside is that the answer arrives while the user is already waiting for the next screen. A fit for low-traffic forms.

Deferred. The form accepts any address that passes the regex. Full verification runs in the background after signup, bad addresses get flagged, and the user receives a request to confirm. Gentlest on conversion, but junk does get into the database.

Security: what not to do

Never call the validation API from the browser with your account key. The x-api-key key does not expire, and anyone can pull it out of client code. Route every external API call through your backend.

Put a rate limit on your own /api/validate-email endpoint. Without one, a bot can fire thousands of requests and burn through your credit balance in minutes. A handful of requests per minute per IP is more than any human needs.

Bot signups: why bots fill in your form

Bot signups are signup or subscription form submissions made by software rather than people. OWASP lists the pattern in its catalog of automated threats as OAT-019, Account Creation: accounts created in bulk through the normal sign-up process and misused afterwards. A newsletter form suffers the same way, except what piles up is addresses with no real subscriber behind them.

Subscription bombing. A bot enters someone else's address into hundreds of forms, and the victim is buried in confirmation emails. Spamhaus documented a wave of these attacks in 2016. One company saw nine addresses signed up more than 9,000 times over two weeks, which produced 81,000 confirmation emails, sent by the signup forms of ordinary sites like yours.

Reputation damage. Nonexistent and unconsenting addresses in the list turn your next campaign into bounces and spam complaints. Sometimes that is the goal; a random attack hurts the sender just as much.

Spam in form fields. A link or an ad shows up in the "Name" field, in the hope that a welcome email or a public profile will show it to someone else.

An attack is usually easy to spot. Daily signups jump several times over the normal level, requests arrive from one IP or a narrow range seconds apart, and the addresses look alike (test1234@…, asdf8765@…). With double opt-in, almost none of the new signups confirm. The normalization step above catches one more sign: a dozen "different" addresses that collapse into one gmail.com mailbox once the dots are removed. Google says outright that it cannot stop people from using a dotted version of an address to sign someone up for subscriptions.

What helps. Confirmed opt-in comes first: Google's sender guidelines ask you to confirm each recipient's address before subscribing them. A bot can type an address but cannot click the link in someone else's inbox, so an unconfirmed address never reaches your sending list. It does nothing against bombing, though, since the confirmation emails are the attack. That is why Spamhaus recommends a CAPTCHA on the form together with confirmed opt-in.

Then the lighter layers. A honeypot, a hidden field that people never see and simple bots fill in. A per-IP limit on submissions on your server. API verification removes nonexistent mailboxes, but a bot can just as easily submit a real address that belongs to someone else, so against bots it only works alongside the rest.

<!-- hidden from people; a bot will fill it in -->
<input type="text" name="website" tabindex="-1" autocomplete="off"
       style="position:absolute;left:-9999px" aria-hidden="true">

// on the server
if (req.body.website) return res.status(400).end(); // honeypot filled

Poisoned email data: what else gets into the list

Poisoned data is the name for addresses that ended up in your list to the sender's harm: bot signups, real people's addresses entered without their knowledge, spam traps, disposable inboxes. None of it shows until the first send.

Bots aside, there are three common sources. Purchased lists: Google's guidelines tell senders not to buy addresses from other companies, and Amazon SES adds renting and sharing lists to the same ban. Addresses scraped from websites, where, according to the Amazon SES FAQ, spam traps are often seeded on purpose. And junk people type themselves to grab a download without subscribing, things like test@test.com. That passes a regex, and only the SMTP check decides what happens to it.

Bounces and complaints from these addresses drag down domain reputation, and trap hits lead to blocklists. How traps work, and why address verification cannot find an active one, is covered in our guide to spam traps.

uChecker catches part of this. A nonexistent or disabled mailbox, or a domain that does not accept mail, gets bad. A domain with no MX records, with MX servers that do not respond, one that looks like a disposable service, one on a blocklist, or a catch-all domain gets unknown. A real address that belongs to someone else, entered by a bot, will pass, because the mailbox exists. Only confirmed opt-in stops that one.

Integration checklist

What needs to be in place for solid email validation in a signup form:

1. Client-side regex (instant feedback).

2. A server-side dedup key (case, gmail.com dots and +labels).

3. API calls from the backend only (the key never reaches the browser).

4. A short wait for the verdict and a webhook for the rest.

5. Graceful degradation: the form works when the API is down.

6. Result caching (no second credit for the same address).

7. A rate limit on your own validation endpoint.

8. Specific error messages and domain typo suggestions.

9. Confirmed opt-in, CAPTCHA and a honeypot against bots.

10. Deferred batch verification for addresses accepted without a full check.

Form-level validation does not replace periodic checks of the whole list. Addresses go stale as people change jobs and providers close abandoned mailboxes. An input filter only slows the decay; you will still clean the list, just less often.

Sources

Try the validation API: sign up at uChecker and get 100 free verifications to test your integration.

email validation signup formreal-time email verificationemail validation APIfrontend email validationJavaScript email validationReact email formbot signupspoisoned email data

Mark Avrilov · Author

Published Updated