# Validate Emails in Webflow Forms

URL: https://email-validation.org/docs/guides/webflow/
Updated: 2026-11-06

> Documentation index: https://email-validation.org/llms.txt

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](#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](https://email-validation.org/docs/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](https://email-validation.org/app/api-keys/):

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](https://email-validation.org/docs/authentication/#limits).

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](#afterwards)).

## Set it up

1. Create the key as described [above](#key).
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

```html
<!-- 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](https://email-validation.org/docs/guides/no-code/): 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](https://email-validation.org/docs/guides/signup-form-validation/).

## Related

- [Validate emails in Framer forms](https://email-validation.org/docs/guides/framer/) and [in Wix Forms](https://email-validation.org/docs/guides/wix/)
- [Statuses](https://email-validation.org/docs/statuses/) and [errors](https://email-validation.org/docs/errors/)
- [Test addresses](https://email-validation.org/docs/test-addresses/)
