Validate emails in RudderStack
Check the email addresses your RudderStack sources collect, with a user transformation you paste into your workspace. It checks the address of identify events and of the track events you name, and adds the result to the event before RudderStack sends it on, so every destination the transformation is connected to receives the status: your warehouse, a CRM, an email tool.
Status: a setup guide, not a catalog destination. email-validation.org is not a RudderStack destination. The transformation on this page works today on any RudderStack workspace with JavaScript transformations. A transformation is the right tool here: a destination could check addresses, but it could not hand the result to your other destinations.
Before you start: what it costs
Every event the transformation checks is one validation, charged like any other check. These are free:
- Every result with status unknown
- Checks that exceed their time limit (10 seconds by default; unknown, reason timeout)
- API errors
- Malformed addresses rejected before any check
- Test addresses on the sandbox domain
The transformation cannot tell whether an address changed. Transformations keep no lasting state, so an identify event that repeats an address already checked is checked, and charged, again. Within one batch of events, an address is checked once. To keep the cost close to one check per new address:
- Check the events that carry a new or changed address, such as
Signed UporEmail Changed: list them inEV_TRACK_EVENTS, and setEV_CHECK_IDENTIFYto false if your app sends identify events on every visit. - Connect the transformation only to the destinations that need the status. It runs once for each destination it is connected to, so the same event sent to two destinations is checked twice.
- Give the transformation its own API key with a daily credit cap, so a busy source can never spend more than you expect.
Where the result goes
The result is four fields. On an identify event they are added next to the address, in context.traits (or traits); on a track event, in properties:
{
"ev_status": "valid",
"ev_reason": "accepted",
"ev_action": "send",
"ev_processed_at": "2026-11-06T12:00:00.000Z"
}The statuses are valid, invalid, catch_all, unknown and disposable; the action says what to do with the address (send, send_with_caution, remove, retry_later). The destinations behind the transformation receive the fields like any other trait or property: in a warehouse they become columns, in an email tool or CRM you can map them to a field and leave out invalid addresses. The transformation changes only the events it sends on; it does not change profiles stored elsewhere.
Set it up
- Create an API key for RudderStack on the API keys page, with a daily credit cap.
- Put the key where the transformation can read it:
- Growth and Enterprise plans: add a secret named
EV_API_KEYto RudderStack's credential store, and leaveEV_API_KEY_IN_CODEempty. - Other plans: RudderStack has no secret store for transformations there, so paste the key into
EV_API_KEY_IN_CODE. Everyone who can open the transformation can read it, which is one more reason for a key used only here, with a daily cap.
- Growth and Enterprise plans: add a secret named
- In RudderStack, open Transformations and create a new JavaScript transformation. Replace its code with the transformation below and set the settings at its top (the list).
- Test it with an identify event whose
context.traits.emailis a free test address, for exampleaccepted@sandbox.email-validation.orgorno_mailbox@sandbox.email-validation.org. The output shows the event with the four fields added. - Save it and connect it to the destinations that should receive the status.
Settings
The settings are the constants at the top of the transformation:
| Constant | Default | What it does |
|---|---|---|
EV_API_KEY_IN_CODE | empty | Your API key, only when there is no EV_API_KEY secret (step 2). |
EV_REGION | us | eu for EU data residency. |
EV_CHECK_IDENTIFY | true | False: identify events are not checked. |
EV_TRACK_EVENTS | none | Track event names to check, e.g. ['Signed Up', 'Email Changed']. The address is the event's properties.email, else context.traits.email. |
Rate limits and errors
The transformation never stops or drops your events because of us. When a check cannot be done, the event goes on without the four fields and the transformation's log (shown when you test it) says why, with the HTTP status, our error code and the request id, never the address.
- Rate limits and outages (HTTP 429 or 503): no check is sent until the
Retry-Aftertime is over; the events in the meantime go on unchecked. RudderStack does not run a transformation again later, so those events stay unchecked. - No answer in time: the transformation asks our API to answer within 5 seconds (a slower check comes back
unknownwith reasontimeout, which is free) and waits a few seconds more. An event whose check got no answer is not checked again, because that check may have finished and been charged on our side. - Account problems (an invalid key, no credits left, the key's daily cap): the events go on unchecked until you fix the cause. See errors for every code.
The transformation
The whole transformation. Every name it adds starts with ev or EV_, so it does not clash with code of yours.
// email-validation.org for RudderStack 1.0.0: a JavaScript user transformation.
// Docs: https://email-validation.org/docs/guides/rudderstack/
//
// It checks the address of identify events (context.traits.email or traits.email) and of the track events you name
// below, and adds the result to the event as ev_status, ev_reason, ev_action and ev_processed_at, so every destination
// this transformation is connected to receives it. Each checked event costs one credit (results with status unknown
// and test addresses are free): a transformation keeps no lasting state, so it cannot tell whether an address changed.
// Within one batch, events with the same address are checked once.
//
// A problem on our side never stops or drops your events: the event goes on without the ev_ traits, and one log line
// says why (never the address).
// ---- Your settings ---------------------------------------------------------------------------------------------
// The API key: on the Growth and Enterprise plans, add a secret named EV_API_KEY in RudderStack's credential store and
// leave EV_API_KEY_IN_CODE empty. Other plans: paste the key here (everyone who can open this transformation can read
// it: use a key with a daily credit cap, only for this).
const EV_API_KEY_IN_CODE = '';
// "eu" for EU data residency; anything else is US.
const EV_REGION = 'us';
// false: identify events are not checked.
const EV_CHECK_IDENTIFY = true;
// Track events to check, by name (their properties.email, else context.traits.email), e.g. ['Signed Up'].
const EV_TRACK_EVENTS = [];
// ---------------------------------------------------------------------------------------------------------------
const EV_VERSION = '1.0.0';
const EV_ORIGINS = { us: 'https://api.email-validation.org', eu: 'https://api-eu.email-validation.org' };
const EV_STATUSES = ['valid', 'invalid', 'catch_all', 'unknown', 'disposable'];
// Our API answers within this many seconds (its shortest wait; a slower check is unknown, reason timeout, free).
const EV_CHECK_TIMEOUT_S = 5;
const EV_FETCH_TIMEOUT_MS = 7000;
// After a 429 or 503 without Retry-After, no check is sent for this long.
const EV_DEFAULT_PAUSE_S = 30;
// Until when no check is sent, after our API asked to wait (Retry-After). Lives as long as RudderStack keeps this
// transformation loaded (at least one batch).
let evPausedUntil = 0;
// Checks running at once.
const EV_CONCURRENCY = 4;
export async function transformBatch(events) {
// One check per distinct address in the batch; every event is returned, in its order, checked or not.
try {
const byAddress = new Map();
for (const event of events) {
const target = evTarget(event);
if (!target) continue;
const key = target.email.toLowerCase();
if (!byAddress.has(key)) byAddress.set(key, { email: target.email, into: [] });
byAddress.get(key).into.push(target.into);
}
const queue = [...byAddress.values()];
const worker = async () => {
for (let item = queue.shift(); item !== undefined; item = queue.shift()) {
const verdict = await evCheck(item.email);
if (verdict) for (const into of item.into) Object.assign(into, verdict);
}
};
await Promise.all(Array.from({ length: EV_CONCURRENCY }, worker));
} catch {
evLog('stopped checking this batch after an unexpected error; the events go on.');
}
return events;
}
// The address to check and the object its result goes into, or null when the event is not one to check.
function evTarget(event) {
if (!event || typeof event !== 'object') return null;
const contextTraits = event.context && typeof event.context === 'object' ? event.context.traits : undefined;
if (event.type === 'identify') {
if (!EV_CHECK_IDENTIFY) return null;
for (const into of [contextTraits, event.traits]) {
const email = evAddress(into);
if (email) return { email, into };
}
return null;
}
if (event.type === 'track' && EV_TRACK_EVENTS.includes(event.event)) {
const fromProperties = evAddress(event.properties);
if (fromProperties) return { email: fromProperties, into: event.properties };
const fromTraits = evAddress(contextTraits);
if (fromTraits) {
if (!event.properties || typeof event.properties !== 'object') event.properties = {};
return { email: fromTraits, into: event.properties };
}
}
return null;
}
function evAddress(traits) {
const value = traits && typeof traits === 'object' && typeof traits.email === 'string' ? traits.email.trim() : '';
return value.includes('@') ? value : '';
}
function evLog(message) {
if (typeof log === 'function') log(`email-validation.org: ${message}`);
}
function evApiKey() {
if (typeof getCredential === 'function') {
try {
const key = getCredential('EV_API_KEY');
if (typeof key === 'string' && key.trim() !== '') return key.trim();
} catch {
// no credential store on this plan: the key in the code is used
}
}
return EV_API_KEY_IN_CODE.trim();
}
// The result as traits, or null when the address was not checked (the reason is logged).
async function evCheck(email) {
const apiKey = evApiKey();
if (!apiKey || /\s/.test(apiKey)) {
evLog('not checked: no API key (EV_API_KEY secret or EV_API_KEY_IN_CODE).');
return null;
}
if (Date.now() < evPausedUntil) {
evLog('not checked: paused after a rate limit.');
return null;
}
const origin = EV_REGION === 'eu' ? EV_ORIGINS.eu : EV_ORIGINS.us;
let response;
try {
response = await fetchV2(`${origin}/v1/validate`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
Accept: 'application/json',
'User-Agent': `email-validation-rudderstack/${EV_VERSION}`,
},
body: JSON.stringify({ email, timeout: EV_CHECK_TIMEOUT_S }),
redirect: 'manual',
timeout: EV_FETCH_TIMEOUT_MS,
});
} catch {
// Never retried: the check may have run and been charged, and a retry would charge it again.
evLog('not checked: no answer from the API in time (not retried).');
return null;
}
const body = response && typeof response.body === 'object' && response.body !== null ? response.body : {};
if (response.status === 200 && EV_STATUSES.includes(body.status)) {
return {
ev_status: body.status,
ev_reason: typeof body.reason === 'string' ? body.reason : '',
ev_action: typeof body.action === 'string' ? body.action : '',
ev_processed_at: typeof body.processed_at === 'string' ? body.processed_at : '',
};
}
const headers = response?.headers ? response.headers : {};
const error = body.error && typeof body.error === 'object' ? body.error : {};
const code = typeof error.code === 'string' ? error.code : 'unexpected_response';
const requestId = typeof error.request_id === 'string' ? error.request_id : headers['x-request-id'];
const where = ` (HTTP ${response.status}, ${code}${requestId ? `, request ${requestId}` : ''})`;
if ((response.status === 429 || response.status === 503) && code !== 'key_daily_cap') {
const after = evRetryAfter(headers['retry-after']);
evPausedUntil = Date.now() + (after === null ? EV_DEFAULT_PAUSE_S : after) * 1000;
evLog(`not checked: rate limited${where}.`);
return null;
}
evLog(`not checked${where}.`);
return null;
}
function evRetryAfter(value) {
if (typeof value !== 'string' || value.trim() === '') return null;
if (/^\d+$/.test(value.trim())) return Number(value.trim());
const at = Date.parse(value);
return Number.isNaN(at) ? null : Math.max(0, Math.ceil((at - Date.now()) / 1000));
}Related
- Segment: the same check as a Segment Function
- Sign-up form validation: check the address before it reaches RudderStack
- Statuses and reasons, errors and rate limits