Validate emails in Webflow forms

Check the email field of your Webflow forms before they are sent. A short script in your site's footer checks the address when the visitor leaves the field or presses submit, shows a message under the field when the address cannot receive mail, and lets every other submission through to Webflow as usual.

Status: set up from this page. There is no Webflow Marketplace app yet: you paste the code below into your site's custom code. To check submissions after they arrive instead, see Check submissions afterwards.

How it behaves

  • Blocks only what is clearly wrong. By default only an invalid result stops the form, with the message "We could not deliver to this address. Please check it for typos." and, when the API has a suggestion (did_you_mean), "Did you mean …?". You can also block disposable and catch_all results.
  • Never stops a real sign-up by mistake. valid, catch_all, unknown and disposable results go through, and so does every submission when something goes wrong: an error, a refused key, no credits left, or no answer within 5 seconds.
  • One check per address. Each address is checked once while the page is open. Pressing submit again, after a refusal or after a success, never pays for the same address twice. Partial addresses are never sent.
  • Accessible. The message appears right after the field, is announced by screen readers and is linked to the field (aria-describedby); the field is marked invalid and gets the focus.
  • Quiet. The script writes nothing to the browser console, so an address is never logged.

Each completed check uses a credit (1 credit = 1 validated address); unknown results, errors and the test addresses are free.

Your key on a public page

The script runs in your visitors' browsers, so the key in it is public: anyone can read it in your page's source. Never paste your main key. Create a key just for this form, on the API keys page:

  1. Permissions: only Validate addresses. The key then cannot read your balance or usage, or create bulk lists.
  2. Allowed origins: your site's addresses, one per line, such as https://www.example.com and https://example.com, plus your webflow.io address if you test there. The API refuses the key when a browser sends it from any other site (403 forbidden_origin), so other websites cannot use it in their visitors' browsers.
  3. Daily credit cap: a little above the sign-ups you get on a busy day.

What the allowed origins do not stop: they are checked against the Origin header, which a browser always sets truthfully but a script outside a browser can set to anything. Anyone who copies the key can still spend checks with it, up to its daily cap, and so can a bot that fills in your form. The daily cap is what limits the cost. If you see a key being misused, roll it on the API keys page and paste the new one. See Limits per key.

Webflow forms cannot run code on a server, so the key cannot be kept off the page here. If that matters more than the instant message, check submissions afterwards with the key on a server (below).

Set it up

  1. Create the key as described above.
  2. In Webflow, open Site settings → Custom code, and paste the code below into Footer code. Replace PASTE_YOUR_BROWSER_KEY_HERE with your key. To check the forms of one page only, paste it into that page's settings (Custom code → Before </body> tag) instead.
  3. Save and publish the site. Webflow runs custom code only on the published site, not in the Designer, and only with a paid site plan.
  4. Test it on the published site with free test addresses: no_mailbox@sandbox.email-validation.org shows the message, likely_typo@sandbox.email-validation.org shows a suggestion, and accepted@sandbox.email-validation.org is sent.

The script checks every field of the Email type (input type="email") in every form of the page. Use Webflow's Email field for the address, not a plain text field.

Settings

The settings are at the top of the code:

  • key: your browser key.
  • region: 'eu' if your account processes data in the EU only; the checks then go to https://api-eu.email-validation.org.
  • block: the results that stop the form. ['invalid'] by default; add 'disposable' to ask for a permanent address, or 'catch_all'. valid and unknown results always go through.
  • messages: your own wording, such as { invalid: 'Please check your email address.' }. The keys are invalid, disposable, catch_all and suggestion (where {suggestion} is the suggested address).

To leave one form unchecked, give the form (or an element around it) the custom attribute data-ev-skip: in the form's Element settings → Custom attributes, name data-ev-skip, no value.

The message is a paragraph with the class ev-email-message, placed right after the field. To style it, add CSS to your site's head code, for example .ev-email-message { color: #b42318; font-size: 14px; margin-top: 4px; }.

The code

<!-- Email check for Webflow forms (email-validation.org, v1.0.0). Guide: https://email-validation.org/docs/guides/webflow/ -->
<script>
(() => {
  // ---- Settings: edit these ----
  const EV_SETTINGS = {
    // A browser key: allowed origins = your site's addresses, only the "Validate addresses" permission, a small daily
    // credit cap. Anyone can read it in your page's source; see the guide for what the key's limits protect.
    key: 'PASTE_YOUR_BROWSER_KEY_HERE',
    // 'us', or 'eu' if your account processes data in the EU only
    region: 'us',
    // Statuses that stop the form: 'invalid' (default), and if you want also 'disposable' and 'catch_all'
    block: ['invalid'],
    // Your own wording, for example { invalid: 'Please check your email address.' }
    messages: {},
  };
  // ---- End of settings ----

  const EV_VERSION = '1.0.0';

  /** The API's two endpoints. */
  const EV_API = Object.freeze({
    us: 'https://api.email-validation.org',
    eu: 'https://api-eu.email-validation.org',
  });

  /** The five statuses an answer can have. */
  const EV_STATUSES = Object.freeze(['valid', 'invalid', 'catch_all', 'unknown', 'disposable']);

  /** The statuses a form may refuse. `valid` and `unknown` always pass. */
  const EV_BLOCKABLE = Object.freeze(['invalid', 'disposable', 'catch_all']);

  /** The API's shortest `timeout`, in seconds: a check that needs longer ends as a free `unknown`. */
  const EV_SERVER_TIMEOUT_S = 5;

  const EV_DEFAULTS = Object.freeze({
    block: Object.freeze(['invalid']),
    /** How long a submit waits for an answer before it goes through anyway. */
    waitMs: 5000,
    /** A request still unanswered after this is dropped (0: never). */
    hardLimitMs: 15000,
    /** How long an answer is remembered (0: as long as the page). */
    cacheTtlMs: 0,
    /** The most addresses remembered (the oldest is forgotten first). */
    cacheMax: 500,
  });

  /** What the person sees when an address is refused. `{suggestion}` is the API's `did_you_mean`. */
  const EV_MESSAGES = Object.freeze({
    invalid: 'We could not deliver to this address. Please check it for typos.',
    disposable: 'Please use an email address you will keep, not a disposable one.',
    catch_all: 'We could not confirm this address. Please use another one.',
    suggestion: 'Did you mean {suggestion}?',
  });

  /** The form under which two fields hold the same address: trimmed, NFC, lowercased. */
  function evAddressKey(value) {
    return String(value ?? '')
      .trim()
      .normalize('NFC')
      .toLowerCase();
  }

  /** Whether a value is worth a check: something@domain.tld with no spaces (anything else is never sent). */
  function evLooksLikeAddress(value) {
    const v = String(value ?? '').trim();
    return v.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@.]{2,}$/.test(v);
  }

  /** Resolves to `promise`'s value, or to `fallback` once `ms` have passed (whichever comes first). */
  function evWithin(promise, ms, fallback) {
    return new Promise((resolve) => {
      const timer = setTimeout(() => resolve(fallback), ms);
      Promise.resolve(promise).then(
        (value) => {
          clearTimeout(timer);
          resolve(value);
        },
        () => {
          clearTimeout(timer);
          resolve(fallback);
        },
      );
    });
  }

  /** An outcome that lets the address through; `error` says why there is no answer (never shown to the person). */
  function evPass(error) {
    return { status: null, block: false, message: '', suggestion: null, error };
  }

  /**
   * A checker for one API key.
   *
   * options: `key` (required; without it every address passes and nothing is sent), `region` ('us' or 'eu') or
   * `apiBase`, `block`, `messages`, `waitMs`, `hardLimitMs`, `cacheTtlMs`, `cacheMax`, `fetch` (default: the global
   * fetch), `now` (default: Date.now).
   */
  function evCreateChecker(options) {
    const o = options || {};
    const key = typeof o.key === 'string' ? o.key.trim() : '';
    const base = String(o.apiBase || EV_API[o.region === 'eu' ? 'eu' : 'us']).replace(/\/+$/, '');
    const block = (Array.isArray(o.block) ? o.block : EV_DEFAULTS.block).filter((s) => EV_BLOCKABLE.includes(s));
    const messages = Object.assign({}, EV_MESSAGES, o.messages || {});
    const waitMs = o.waitMs > 0 ? o.waitMs : EV_DEFAULTS.waitMs;
    const hardLimitMs = o.hardLimitMs >= 0 ? o.hardLimitMs : EV_DEFAULTS.hardLimitMs;
    const cacheTtlMs = o.cacheTtlMs > 0 ? o.cacheTtlMs : EV_DEFAULTS.cacheTtlMs;
    const cacheMax = o.cacheMax > 0 ? o.cacheMax : EV_DEFAULTS.cacheMax;
    const send = o.fetch || ((url, init) => globalThis.fetch(url, init));
    const now = o.now || Date.now;
    /** address key → { at, promise, outcome (null while on its way) } */
    const memo = new Map();

    function outcomeOf(result) {
      const status = result && typeof result.status === 'string' ? result.status : '';
      if (!EV_STATUSES.includes(status)) return evPass('unexpected_answer');
      const refused = block.includes(status) || (block.includes('disposable') && result.disposable === true);
      const reason = block.includes(status) ? status : 'disposable';
      const suggestion = typeof result.did_you_mean === 'string' && result.did_you_mean ? result.did_you_mean : null;
      let message = '';
      if (refused) {
        message = String(messages[reason] || EV_MESSAGES[reason]);
        if (suggestion && messages.suggestion) {
          message = `${message} ${String(messages.suggestion).split('{suggestion}').join(suggestion)}`.trim();
        }
      }
      return { status, block: refused, message, suggestion, error: null };
    }

    async function request(address) {
      let timer = null;
      const init = {
        method: 'POST',
        headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json', Accept: 'application/json' },
        body: JSON.stringify({ email: address, timeout: EV_SERVER_TIMEOUT_S }),
      };
      if (hardLimitMs > 0 && typeof AbortController === 'function') {
        const controller = new AbortController();
        timer = setTimeout(() => controller.abort(), hardLimitMs);
        init.signal = controller.signal;
      }
      try {
        const res = await send(`${base}/v1/validate`, init);
        let body = null;
        try {
          body = await res.json();
        } catch {
          body = null;
        }
        if (!res.ok) {
          const code = body?.error && typeof body.error.code === 'string' ? body.error.code : `http_${res.status}`;
          return evPass(code);
        }
        return outcomeOf(body);
      } catch {
        return evPass('connection_error');
      } finally {
        if (timer !== null) clearTimeout(timer);
      }
    }

    function entry(value) {
      const k = evAddressKey(value);
      const found = memo.get(k);
      if (found && (found.outcome === null || cacheTtlMs === 0 || now() - found.at < cacheTtlMs)) return found;
      memo.delete(k);
      const fresh = { at: now(), outcome: null, promise: null };
      fresh.promise = request(String(value).trim()).then((outcome) => {
        fresh.outcome = outcome;
        fresh.at = now();
        return outcome;
      });
      memo.set(k, fresh);
      while (memo.size > cacheMax) memo.delete(memo.keys().next().value);
      return fresh;
    }

    /** The outcome for an address: one request per address, shared by every ask. Never rejects. */
    function check(value) {
      if (!key) return Promise.resolve(evPass('missing_key'));
      if (!evLooksLikeAddress(value)) return Promise.resolve(evPass('not_an_address'));
      return entry(value).promise;
    }

    return {
      /** False without a key: the checker then sends nothing and lets every address through. */
      ready: key !== '',
      /** The settled outcome for an address, or null when it was never asked or its answer is still on its way. */
      peek(value) {
        if (!key || !evLooksLikeAddress(value)) return null;
        const found = memo.get(evAddressKey(value));
        if (!found || found.outcome === null) return null;
        if (cacheTtlMs > 0 && now() - found.at >= cacheTtlMs) return null;
        return found.outcome;
      },
      check,
      /** check(), but an address with no answer after `ms` (default `waitMs`) passes. The check itself goes on. */
      decide(value, ms) {
        return evWithin(check(value), ms > 0 ? ms : waitMs, evPass('timeout'));
      },
    };
  }

  const EV_FORM_DEFAULTS = Object.freeze({
    emailSelector: 'input[type="email"]',
    skipAttribute: 'data-ev-skip',
    messageClass: 'ev-email-message',
    debounceMs: 400,
  });

  const EV_GUARD_MARK = '__emailValidationFormCheck';

  /** Starts guarding the forms of `doc` with `checker`; returns a function that stops it. */
  function evGuardForms(doc, checker, options) {
    const o = options || {};
    const win = doc.defaultView;
    if (!win || !checker?.ready || win[EV_GUARD_MARK]) return () => {};
    const selector = o.emailSelector || EV_FORM_DEFAULTS.emailSelector;
    const skipAttribute = o.skipAttribute || EV_FORM_DEFAULTS.skipAttribute;
    const messageClass = o.messageClass || EV_FORM_DEFAULTS.messageClass;
    const debounceMs = o.debounceMs >= 0 ? o.debounceMs : EV_FORM_DEFAULTS.debounceMs;
    const passing = new WeakSet();
    const waiting = new WeakSet();
    const timers = new WeakMap();
    const notes = new WeakMap();
    let count = 0;

    function formOf(el) {
      const form = el?.form ? el.form : el?.closest ? el.closest('form') : null;
      return form && !form.closest(`[${skipAttribute}]`) ? form : null;
    }

    function isEmailField(el) {
      return Boolean(el?.matches?.(selector) && formOf(el));
    }

    function fieldsOf(form) {
      return Array.from(form.querySelectorAll(selector)).filter((i) => !i.disabled && evLooksLikeAddress(i.value));
    }

    function show(input, outcome) {
      let note = notes.get(input);
      if (!outcome?.block) {
        if (note) {
          note.textContent = '';
          note.hidden = true;
        }
        if (input.getAttribute('aria-invalid') === 'true') input.removeAttribute('aria-invalid');
        return;
      }
      if (!note) {
        count += 1;
        note = doc.createElement('p');
        note.id = `ev-email-message-${count}`;
        note.className = messageClass;
        note.setAttribute('role', 'alert');
        input.insertAdjacentElement('afterend', note);
        const described = (input.getAttribute('aria-describedby') || '').split(/\s+/).filter(Boolean);
        input.setAttribute('aria-describedby', described.concat(note.id).join(' '));
        notes.set(input, note);
      }
      note.hidden = false;
      note.textContent = outcome.message;
      input.setAttribute('aria-invalid', 'true');
    }

    /** Shows each field's outcome (a message, or none) and focuses the first refused field. */
    function mark(fields, outcomes) {
      let first = null;
      fields.forEach((input, i) => {
        show(input, outcomes[i]);
        if (!first && outcomes[i] && outcomes[i].block) first = input;
      });
      if (first && typeof first.focus === 'function') first.focus();
    }

    function resubmit(form, submitter) {
      passing.add(form);
      try {
        if (typeof form.requestSubmit === 'function') {
          try {
            form.requestSubmit(submitter && submitter.form === form ? submitter : undefined);
          } catch {
            form.requestSubmit();
          }
        } else if (form.dispatchEvent(new win.Event('submit', { bubbles: true, cancelable: true }))) {
          form.submit();
        }
      } finally {
        passing.delete(form);
      }
    }

    function onInput(event) {
      const input = event.target;
      if (!isEmailField(input)) return;
      clearTimeout(timers.get(input));
      show(input, null);
    }

    function onChange(event) {
      const input = event.target;
      if (!isEmailField(input) || !evLooksLikeAddress(input.value)) return;
      clearTimeout(timers.get(input));
      const value = input.value;
      timers.set(
        input,
        setTimeout(() => {
          checker.check(value).then((outcome) => {
            if (input.value === value) show(input, outcome);
          });
        }, debounceMs),
      );
    }

    function onSubmit(event) {
      const form = event.target;
      if (form?.tagName !== 'FORM' || passing.has(form) || form.closest(`[${skipAttribute}]`)) return;
      const fields = fieldsOf(form);
      if (fields.length === 0) return;
      const known = fields.map((input) => checker.peek(input.value));
      if (known.every((k) => k && !k.block)) {
        mark(fields, known);
        return;
      }
      event.preventDefault();
      event.stopImmediatePropagation();
      if (known.every(Boolean)) {
        mark(fields, known);
        return;
      }
      if (waiting.has(form)) return;
      waiting.add(form);
      const values = fields.map((input) => input.value);
      const submitter = event.submitter || null;
      Promise.all(fields.map((input) => checker.decide(input.value))).then((outcomes) => {
        waiting.delete(form);
        if (fields.some((input, i) => input.value !== values[i])) return;
        mark(fields, outcomes);
        if (!outcomes.some((x) => x.block)) resubmit(form, submitter);
      });
    }

    win.addEventListener('submit', onSubmit, true);
    doc.addEventListener('input', onInput, true);
    doc.addEventListener('change', onChange, true);
    win[EV_GUARD_MARK] = true;
    return () => {
      win.removeEventListener('submit', onSubmit, true);
      doc.removeEventListener('input', onInput, true);
      doc.removeEventListener('change', onChange, true);
      delete win[EV_GUARD_MARK];
    };
  }

  if (!/^PASTE_/.test(EV_SETTINGS.key)) evGuardForms(document, evCreateChecker(EV_SETTINGS), EV_SETTINGS);
})();
</script>

Check submissions afterwards

To keep the key off the page, or to tag every address once it has arrived, check submissions on a server instead (or as well). This does not stop a submission; it labels it after the fact.

  1. Send each submission to an automation: use the Webflow trigger for new form submissions in Zapier, Make or n8n, or add a webhook with the Form submission trigger in Site settings → Apps & integrations → Webhooks and point it at your automation's webhook step. The submitted fields arrive under data, named as in your form.
  2. Validate the address with an HTTP step, as in the no-code recipes: the key stays in the automation's settings.
  3. Use status and action to tag the contact in your CRM or email list, or to skip it.

For the server side of a sign-up form in general, see Validate emails on sign-up forms.

Updated · Maintained by the email-validation.org team