Validate emails in Segment
Check the email addresses your Segment sources collect, with a Segment Function you paste into your workspace. It checks the address of identify calls and of the track events you name, and adds the result to the event, so the destinations behind it receive the status. Optionally it sends the result back into Segment as traits, for your profiles and your warehouse.
Status: a setup guide, not a catalog destination. email-validation.org is not in Segment's destination catalog yet. The function on this page works today on any Segment workspace that has Functions. A catalog destination is built and waiting to be submitted to Segment (below).
Before you start: what it costs
Every event the function 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 function cannot tell whether an address changed. Segment Functions keep no lasting state, so an identify call that repeats an address already checked is checked, and charged, again. The function keeps results in Segment's cache while its process runs, which saves some repeat checks, but Segment can empty that cache at any time, so do not count on it. 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: name them in thetrackEventssetting, and setcheckIdentifyto false if your app sends identify calls on every visit. - Give the function 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, added to the identify call's traits or the track event's 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). What reaches your tools depends on how you add the function:
- As an insert function (recommended): you attach it to a destination, and the destination receives each event with the fields above, for example to suppress invalid addresses in your email tool or CRM. Segment runs insert functions only in front of cloud-mode destinations, not storage destinations such as warehouses.
- As a destination function: it sees the events but cannot change them or the profile they came from. Its result reaches Segment only through the write-back below. Without a write key, a destination function's checks are charged and the results are seen only in the function's logs, so always set one in this mode.
- Write-back (either mode): with the
writeKeysetting, each result is also sent to a Segment HTTP API source of yours as an identify call with the four fields as traits. From there it reaches your profiles, your warehouse and every destination connected to that source.
Set it up
- Create an API key for Segment on the API keys page, with a daily credit cap.
- In Segment, go to Connections → Catalog, open the Functions tab and click New Function. Choose Insert (or Destination, see above).
- Replace the editor's code with the function below.
- Add the settings in the function's Settings tab (the list): at least
apiKey, marked as secret. - In the Test tab, send an identify call with a free test address, for example
accepted@sandbox.email-validation.orgorno_mailbox@sandbox.email-validation.orgastraits.email. The output shows the event with the four fields added. - Save and deploy the function, then connect it: an insert function to the destination it should enrich, a destination function to the source whose events it should check.
Settings
| Name | Type | Required | What it does |
|---|---|---|---|
apiKey | String, secret | Yes | Your API key. |
region | String | No | eu for EU data residency; anything else is the US endpoint. |
checkIdentify | Boolean | No | False: identify calls are not checked. Default: checked. |
trackEvents | String | No | Track event names to check, separated by commas, e.g. Signed Up, Email Changed. The address is the event's properties.email, else context.traits.email. |
writeKey | String, secret | No | The write key of a Segment HTTP API source: each result is also sent there (write-back). |
writeKeyRegion | String | No | eu when that source's workspace is in Segment's EU region. |
retryLater | Boolean | No | True: on a rate limit or an outage, Segment tries the event again later instead of passing it on unchecked. Only for a destination function (details). |
The write-back source
- In Segment, add a source of type HTTP API (for example named "Email validation results") and copy its write key into the function's
writeKeysetting. - Connect that source to the destinations that should receive the results: your warehouse, a CRM, an email tool.
Each result arrives there as an identify call with the user's userId (or anonymousId) and the four fields as traits. The address is never sent back, so if the function also checks that source's events, it finds nothing to check: there is no loop and no charge. Events with neither id get no write-back.
Rate limits and errors
The function 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 function's log says why, with the HTTP status, our error code and the request id, never the address.
- Rate limits and outages (HTTP 429 or 503): the function sends no check until the
Retry-Aftertime is over; events in the meantime go on unchecked. WithretryLaterset to true it throws a retry error instead, and Segment tries the event again later; nothing was charged for it. Use that only in a destination function: in an insert function a retried event reaches its destination late. - No answer in time: Segment stops a function after 5 seconds, so the function waits up to about 4 seconds for a check (it asks our API to answer within 5 seconds; a slower check comes back
unknownwith reasontimeout, which is free). 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 function
The whole function. Every name it adds starts with ev, so it does not clash with code of yours.
// email-validation.org for Segment 1.0.0: a Segment Function (paste it as a destination function or an insert function).
// Docs: https://email-validation.org/docs/guides/segment/
//
// It checks the address of identify events (traits.email) and of the track events you name, and adds the result to
// the event as ev_status, ev_reason, ev_action and ev_processed_at. Each checked event costs one credit (results
// with status unknown and test addresses are free): Segment Functions keep no lasting state, so the function cannot
// tell whether an address changed. A cache inside the function saves repeat checks of the same address only while
// Segment keeps the function's process running.
//
// Settings (Settings tab of the function):
// apiKey Secret, required. Your email-validation.org API key (give it a daily credit cap).
// region Optional. "eu" for EU data residency; anything else is US.
// checkIdentify Optional boolean. false: identify events are not checked (default: checked).
// trackEvents Optional. Comma-separated track event names to check, e.g. "Signed Up, Email Changed".
// writeKey Secret, optional. A Segment HTTP API source's write key: the result is also sent there as an
// identify call with the ev_ traits (never the address), for your profiles and warehouse.
// writeKeyRegion Optional. "eu" when that source's workspace is in the EU.
// retryLater Optional boolean. true: a rate limit (429) or outage (503) throws RetryError, so Segment tries the
// event again later. Use it only in a destination function: in an insert function a retried
// event reaches its destination late. Default: the event goes on unchecked.
//
// A problem on our side never stops your events: the event goes on without the ev_ traits, and one log line says
// why (never the address).
const EV_VERSION = '1.0.0';
const EV_USER_AGENT = `email-validation-segment-function/${EV_VERSION}`;
const EV_ORIGINS = { us: 'https://api.email-validation.org', eu: 'https://api-eu.email-validation.org' };
const EV_SEGMENT_IDENTIFY = {
us: 'https://api.segment.io/v1/identify',
eu: 'https://events.eu1.segmentapis.com/v1/identify',
};
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;
// Segment stops a function after 5 seconds: the check and the write-back each get a share of that.
const EV_CHECK_LIMIT_MS = 3800;
const EV_WRITE_BACK_LIMIT_MS = 900;
// How long a result is kept in the function's cache (process-local; Segment may empty it at any time).
const EV_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
// 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). Process-local.
let evPausedUntil = 0;
// biome-ignore lint/correctness/noUnusedVariables: Segment calls each handler by its name
async function onIdentify(event, settings) {
if (settings.checkIdentify === false || settings.checkIdentify === 'false') return event;
const email = evAddress(event.traits);
if (!email) return event;
const verdict = await evCheck(email, settings);
if (!verdict) return event;
event.traits = Object.assign({}, event.traits, verdict);
await evWriteBack(event, verdict, settings);
return event;
}
// biome-ignore lint/correctness/noUnusedVariables: Segment calls each handler by its name
async function onTrack(event, settings) {
if (!evNamed(settings.trackEvents, event.event)) return event;
const email = evAddress(event.properties) || evAddress(event.context?.traits);
if (!email) return event;
const verdict = await evCheck(email, settings);
if (!verdict) return event;
event.properties = Object.assign({}, event.properties, verdict);
await evWriteBack(event, verdict, settings);
return event;
}
// An insert function blocks every event type it has no handler for: these pass the others on unchanged.
// biome-ignore lint/correctness/noUnusedVariables: Segment calls each handler by its name
async function onPage(event) {
return event;
}
// biome-ignore lint/correctness/noUnusedVariables: Segment calls each handler by its name
async function onScreen(event) {
return event;
}
// biome-ignore lint/correctness/noUnusedVariables: Segment calls each handler by its name
async function onGroup(event) {
return event;
}
// biome-ignore lint/correctness/noUnusedVariables: Segment calls each handler by its name
async function onAlias(event) {
return event;
}
// biome-ignore lint/correctness/noUnusedVariables: Segment calls each handler by its name
async function onDelete(event) {
return event;
}
function evAddress(traits) {
const value = traits && typeof traits.email === 'string' ? traits.email.trim() : '';
return value.includes('@') ? value : '';
}
function evNamed(list, name) {
if (typeof list !== 'string' || typeof name !== 'string') return false;
return list
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.includes(name);
}
function evLog(message) {
console.log(`email-validation.org: ${message}`);
}
function evIsTrue(value) {
return value === true || value === 'true';
}
// The result as traits, or null when the address was not checked (the reason is logged).
async function evCheck(email, settings) {
const apiKey = typeof settings.apiKey === 'string' ? settings.apiKey.trim() : '';
if (!apiKey || /\s/.test(apiKey)) {
evLog('not checked: the apiKey setting is missing.');
return null;
}
if (Date.now() < evPausedUntil) return evLimited(settings, 'paused after a rate limit');
const region = settings.region === 'eu' ? 'eu' : 'us';
const load = () => evValidate(EV_ORIGINS[region], apiKey, email, settings);
if (typeof cache === 'undefined' || !cache || typeof cache.load !== 'function') return load();
const key = `ev:${region}:${email.toLowerCase()}`;
const verdict = await cache.load(key, EV_CACHE_TTL_MS, load);
// Only charged results are kept: unknown is free and always checked again; null means not checked.
if (!verdict || verdict.ev_status === 'unknown') cache.delete(key);
return verdict;
}
async function evValidate(origin, apiKey, email, settings) {
let response;
try {
response = await fetch(`${origin}/v1/validate`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
Accept: 'application/json',
'User-Agent': EV_USER_AGENT,
},
body: JSON.stringify({ email: email, timeout: EV_CHECK_TIMEOUT_S }),
redirect: 'manual',
timeout: EV_CHECK_LIMIT_MS,
signal: evSignal(EV_CHECK_LIMIT_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;
}
let body = null;
try {
body = await response.json();
} catch {
body = null;
}
if (response.status === 200 && body && 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 error = body?.error ? body.error : {};
const code = typeof error.code === 'string' ? error.code : 'unexpected_response';
const requestId = typeof error.request_id === 'string' ? error.request_id : response.headers.get('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(response.headers.get('retry-after'));
evPausedUntil = Date.now() + (after === null ? EV_DEFAULT_PAUSE_S : after) * 1000;
return evLimited(settings, `rate limited${where}`);
}
evLog(`not checked${where}.`);
return null;
}
// A rate limit: with retryLater, Segment tries the event again later (nothing was charged); else it goes on unchecked.
function evLimited(settings, why) {
if (evIsTrue(settings.retryLater)) throw new RetryError(`email-validation.org: ${why}; Segment retries later.`);
evLog(`not checked: ${why}.`);
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));
}
function evSignal(ms) {
return typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
? AbortSignal.timeout(ms)
: undefined;
}
// The result to the customer's own Segment source: ids and ev_ traits only, never the address, so the call has
// nothing for this function to check if that source feeds it too (no loop, no charge). Never retried: the check was
// already charged.
async function evWriteBack(event, verdict, settings) {
const writeKey = typeof settings.writeKey === 'string' ? settings.writeKey.trim() : '';
if (!writeKey) return;
const userId = typeof event.userId === 'string' && event.userId ? event.userId : undefined;
const anonymousId = typeof event.anonymousId === 'string' && event.anonymousId ? event.anonymousId : undefined;
if (!userId && !anonymousId) return;
const url = settings.writeKeyRegion === 'eu' ? EV_SEGMENT_IDENTIFY.eu : EV_SEGMENT_IDENTIFY.us;
try {
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Basic ${btoa(`${writeKey}:`)}`,
'Content-Type': 'application/json',
'User-Agent': EV_USER_AGENT,
},
body: JSON.stringify({ userId: userId, anonymousId: anonymousId, traits: verdict }),
redirect: 'manual',
timeout: EV_WRITE_BACK_LIMIT_MS,
signal: evSignal(EV_WRITE_BACK_LIMIT_MS),
});
if (response.status < 200 || response.status >= 300) evLog(`write-back failed (HTTP ${response.status}).`);
} catch {
evLog('write-back failed (no answer).');
}
}The catalog destination (not available yet)
A destination for Segment's catalog is built and tested, in the shape Segment uses for its catalog destinations (Destination Actions). It is not submitted to Segment yet, so it cannot be added from the catalog today. Once Segment lists it, it will check the address of the events you subscribe it to (a preset subscribes it to Signed Up track events) and, with a write key, send the result back the same way as the function. It will cost the same: one check per event it receives. Unlike an insert function, it cannot add the result to the events your other destinations receive, so the write-back is the only way its results reach Segment. This page will say when it is listed.
Related
- RudderStack: the same check as a RudderStack transformation
- Sign-up form validation: check the address before it reaches Segment
- Statuses and reasons, errors and rate limits