Validate emails in Wix Forms
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
invalidresult 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 blockdisposableandcatch_allresults. - Never stops a real sign-up by mistake.
valid,catch_all,unknownanddisposableresults 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 are free.
Set it up
- Create a key on the API keys page with only the Validate addresses permission and a daily credit cap 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.
- Store it as a secret. In your site's dashboard, open Developer Tools → Secrets Manager, choose Store Secret, name it
EMAIL_VALIDATION_API_KEYand paste the key as its value. - 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. - Paste the code. Replace the contents of the file whose name ends in
-config.jswith the configuration, and the other file with the plugin code. If Wix put animportline at the top of a file, you can keep it. - Publish the site, then test it on the live site with free test addresses:
no_mailbox@sandbox.email-validation.orgshows the message,likely_typo@sandbox.email-validation.orgshows a suggestion andaccepted@sandbox.email-validation.orgis 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 tohttps://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'.validandunknownresults 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 areinvalid,disposable,catch_allandsuggestion(where{suggestion}is the suggested address).
Configuration file
// 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
// 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 };
};