# Validate Emails in RudderStack

URL: https://email-validation.org/docs/guides/rudderstack/
Updated: 2026-11-10

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

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 Up` or `Email Changed`: list them in
  `EV_TRACK_EVENTS`, and set `EV_CHECK_IDENTIFY` to 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](https://email-validation.org/docs/authentication/#limits)**, 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`:

```json
{
  "ev_status": "valid",
  "ev_reason": "accepted",
  "ev_action": "send",
  "ev_processed_at": "2026-11-06T12:00:00.000Z"
}
```

The [statuses](https://email-validation.org/docs/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

1. Create an API key for RudderStack on the [API keys page](https://email-validation.org/app/api-keys/), with a daily credit cap.
2. Put the key where the transformation can read it:
   - **Growth and Enterprise plans:** add a secret named `EV_API_KEY` to RudderStack's credential store, and leave
     `EV_API_KEY_IN_CODE` empty.
   - **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.
3. In RudderStack, open **Transformations** and create a new **JavaScript** transformation. Replace its code with the
   [transformation below](#code) and set the settings at its top ([the list](#settings)).
4. Test it with an identify event whose `context.traits.email` is a free [test address](https://email-validation.org/docs/test-addresses/), for
   example `accepted@sandbox.email-validation.org` or `no_mailbox@sandbox.email-validation.org`. The output shows the event with the four
   fields added.
5. 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](#setup)). |
| `EV_REGION` | `us` | `eu` for [EU data residency](https://email-validation.org/docs/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-After` time 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 `unknown` with reason `timeout`, 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](https://email-validation.org/docs/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.

```js
// 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](https://email-validation.org/docs/guides/segment/): the same check as a Segment Function
- [Sign-up form validation](https://email-validation.org/docs/guides/signup-form-validation/): check the address before it reaches RudderStack
- [Statuses and reasons](https://email-validation.org/docs/statuses/), [errors](https://email-validation.org/docs/errors/) and [rate limits](https://email-validation.org/docs/rate-limits/)
