# Validate Emails in Framer Forms

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

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

Check the email field of your Framer forms before they are sent. An invisible code component 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 Framer as usual.

**Status: set up from this page.** There is no Framer Marketplace plugin yet: you add the code file below to your
project.

## 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 …?". A toggle also blocks `disposable` 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 component 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 component runs in your visitors' browsers, so the key in its settings is public: anyone can read it in your
published site's code. Never use your main key. Create a key just for this, 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`, plus your
   `framer.website` address if you use it. 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).

A Framer site has no server of its own, so the key cannot be kept off the page here. To check addresses with the
key on a server instead, send your form's submissions to an automation (Framer forms can send to a webhook) and
validate them there, as in the [no-code recipes](https://email-validation.org/docs/guides/no-code/).

## Set it up

1. Create the key as described [above](#key).
2. In Framer, open **Assets → Code**, create a new code file named `EmailCheck`, replace its contents with the
   [code below](#code) and save.
3. Drag **EmailCheck** from your assets onto each page that has a form; anywhere on the page will do. On the
   canvas it shows a small label; on the published site it is invisible.
4. In its properties, paste your key into **Browser key**. Optionally turn on **Block disposable** and change the
   messages.
5. **Publish** the site, then 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. In Framer's preview, the key may be refused for the preview's address;
   the form then goes through unchecked.

The component checks every field of the **Email** type (`input type="email"`) in every form of its page, Framer's
own forms included. If the account processes data in the EU only, set **Region** to **EU**; the checks then go to
`https://api-eu.email-validation.org`.

The message is a paragraph with the class `ev-email-message`, placed right after the field.

## The code

```js
// EmailCheck: email check for Framer forms (email-validation.org, v1.0.0). Guide: https://email-validation.org/docs/guides/framer/
import { addPropertyControls, ControlType, RenderTarget } from 'framer';
import { useEffect } from 'react';

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];
  };
}

/**
 * @framerSupportedLayoutWidth any
 * @framerSupportedLayoutHeight any
 */
export default function EmailCheck(props) {
  const {
    apiKey = '',
    region = 'us',
    blockDisposable = false,
    invalidMessage = EV_MESSAGES.invalid,
    disposableMessage = EV_MESSAGES.disposable,
  } = props;
  const onCanvas = RenderTarget.current() === RenderTarget.canvas;
  useEffect(() => {
    if (onCanvas) return undefined;
    const settings = {
      key: apiKey,
      region,
      block: blockDisposable ? ['invalid', 'disposable'] : ['invalid'],
      messages: { invalid: invalidMessage, disposable: disposableMessage },
    };
    return evGuardForms(document, evCreateChecker(settings), settings);
  }, [onCanvas, apiKey, region, blockDisposable, invalidMessage, disposableMessage]);
  if (!onCanvas) return null;
  return (
    <div
      style={{
        padding: 8,
        border: '1px dashed currentColor',
        borderRadius: 6,
        fontSize: 12,
        opacity: 0.7,
      }}
    >
      {apiKey ? 'Email check (invisible on the site)' : 'Email check: add your browser key'}
    </div>
  );
}

addPropertyControls(EmailCheck, {
  apiKey: {
    type: ControlType.String,
    title: 'Browser key',
    description: 'A key with allowed origins, the Validate permission only and a daily cap.',
    defaultValue: '',
  },
  region: {
    type: ControlType.Enum,
    title: 'Region',
    options: ['us', 'eu'],
    optionTitles: ['US', 'EU'],
    defaultValue: 'us',
  },
  blockDisposable: { type: ControlType.Boolean, title: 'Block disposable', defaultValue: false },
  invalidMessage: {
    type: ControlType.String,
    title: 'Invalid message',
    displayTextArea: true,
    defaultValue: EV_MESSAGES.invalid,
  },
  disposableMessage: {
    type: ControlType.String,
    title: 'Disposable message',
    displayTextArea: true,
    defaultValue: EV_MESSAGES.disposable,
    hidden: (p) => !p.blockDisposable,
  },
});
```

## Related

- [Validate emails in Webflow forms](https://email-validation.org/docs/guides/webflow/) and [in Wix Forms](https://email-validation.org/docs/guides/wix/)
- [Validate emails on sign-up forms](https://email-validation.org/docs/guides/signup-form-validation/)
- [Statuses](https://email-validation.org/docs/statuses/) and [errors](https://email-validation.org/docs/errors/)
