# Validate Emails in Wix Forms

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

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

Check the email field of your Wix Forms when they are submitted. A Velo service plugin runs on Wix's servers for
each submission: when the address cannot receive mail, Wix Forms shows a message on the field and the form is not
sent; every other submission goes through as usual.

**Status: set up from this page.** There is no Wix App Market app yet: you add the plugin's code to your site in
the editor's coding mode (Velo).

## Why on the server

The check runs inside Wix Forms' own submission, on Wix's servers (a **Form Submission** service plugin). Your API
key stays in the site's **Secrets Manager**, visitors cannot read it, and a browser cannot skip the check. Bots that
fill in your form still use checks, so give the key a daily credit cap.

The plugin covers forms made with the **Wix Forms** app. Forms of other apps are not checked.

## 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: a missing secret, an error, a refused key, no credits
  left, or no answer within 5 seconds.
- **Not paid twice for a retry.** The plugin remembers each answer for 10 minutes, so a visitor who
  presses submit again does not pay for the same address twice. (Wix may run the plugin on several servers, each
  with its own memory, so a repeat is not always answered from memory.)
- **Quiet.** The plugin writes nothing to the site's logs, 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.

## Set it up

1. **Create a key** on the [API keys page](https://email-validation.org/app/api-keys/) with only the **Validate addresses** permission and a
   [daily credit cap](https://email-validation.org/docs/authentication/#limits) a little above the sign-ups you get on a busy day. Leave
   **Allowed origins** empty: the checks come from Wix's servers, not from a browser, and a key with allowed
   origins is refused there.
2. **Store it as a secret.** In your site's dashboard, open **Developer Tools → Secrets Manager**, choose **Store
   Secret**, name it `EMAIL_VALIDATION_API_KEY` and paste the key as its value.
3. **Add the plugin.** In the editor, turn on coding (**Dev Mode** or **Start Coding**). Under **Public & Backend**,
   next to **Service Plugins**, click **+** and choose **Form Submission**. Follow the prompts, give the plugin a
   name without spaces (such as `emailCheck`) and click **Add & Edit Code**. Wix creates a folder with two files.
4. **Paste the code.** Replace the contents of the file whose name ends in `-config.js` with the
   [configuration](#config-code), and the other file with the [plugin code](#plugin-code). If Wix put an `import`
   line at the top of a file, you can keep it.
5. **Publish** the site, then test it on the live 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.

## Settings

The settings are at the top of the plugin code:

- **secretName**: the name of the secret that holds the key (`EMAIL_VALIDATION_API_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.
- **fieldKeys**: the fields to check. Empty (the default), the plugin checks every field whose key starts with
  `email`. If your email field's key starts differently, list the keys to check, such as
  `['contact_address']`; other fields are then left alone.
- **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).

## Configuration file

```js
// Email check for Wix Forms (email-validation.org, v1.0.0): the plugin's configuration. Guide: https://email-validation.org/docs/guides/wix/
export function getConfig() {
  return {
    namespaceConfigs: [
      {
        namespace: 'wix.form_app.form',
        submissionValidationEnabled: true,
      },
    ],
  };
}
```

## Plugin code

```js
// Email check for Wix Forms (email-validation.org, v1.0.0). Guide: https://email-validation.org/docs/guides/wix/
import { elevate } from 'wix-auth';
import { fetch } from 'wix-fetch';
import { secrets } from 'wix-secrets-backend.v2';

// ---- Settings: edit these ----
const EV_SETTINGS = {
  // The name of the secret that holds your API key (Developer Tools → Secrets Manager)
  secretName: 'EMAIL_VALIDATION_API_KEY',
  // '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'],
  // The field keys to check; empty: every field whose key starts with "email"
  fieldKeys: [],
  // 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'));
    },
  };
}

/** How long the key and the answers are kept by this backend instance. */
const EV_KEEP_MS = 10 * 60 * 1000;
const evGetSecret = elevate(secrets.getSecretValue);
let evBackend = null;

async function evChecker() {
  if (evBackend && Date.now() - evBackend.at < EV_KEEP_MS) return evBackend.checker;
  const secret = await evGetSecret(EV_SETTINGS.secretName);
  const key = typeof secret === 'string' ? secret : secret && typeof secret.value === 'string' ? secret.value : '';
  const checker = evCreateChecker(
    Object.assign({}, EV_SETTINGS, {
      key,
      fetch: (url, init) => fetch(url, init),
      hardLimitMs: 0,
      cacheTtlMs: EV_KEEP_MS,
      cacheMax: 1000,
    }),
  );
  if (checker.ready) evBackend = { at: Date.now(), checker };
  return checker;
}

function evFieldsToCheck(values) {
  return Object.keys(values).filter((name) => {
    if (typeof values[name] !== 'string' || values[name].trim() === '') return false;
    return EV_SETTINGS.fieldKeys.length > 0 ? EV_SETTINGS.fieldKeys.includes(name) : /^email/i.test(name);
  });
}

export const validateSubmission = async (options, _context) => {
  const values = options?.submission?.submissions || {};
  const fields = evFieldsToCheck(values);
  if (fields.length === 0) return { errors: [] };
  const work = evChecker().then((checker) => Promise.all(fields.map((name) => checker.decide(values[name]))));
  const outcomes = await evWithin(
    work,
    EV_DEFAULTS.waitMs,
    fields.map(() => evPass('timeout')),
  );
  const errors = [];
  fields.forEach((name, i) => {
    if (outcomes[i].block) errors.push({ errorPath: name, customErrorMessage: outcomes[i].message });
  });
  return { errors };
};
```

## Related

- [Validate emails in Webflow forms](https://email-validation.org/docs/guides/webflow/) and [in Framer forms](https://email-validation.org/docs/guides/framer/)
- [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/)
