Validate emails in Klaviyo
Check the email address of each new Klaviyo profile and save the result on the profile, so your segments and flow splits can use it. Two small pieces do the work, and you can use either or both:
- A flow-webhook relay checks a profile within seconds, when a Klaviyo flow sends it there.
- A sync script checks every profile created after a time that has no result yet: for a backfill, to catch up, or on a schedule instead of the relay.
Both run in your own accounts with your own keys: the relay as a Cloudflare Worker, the script with Node. Nothing needs to be installed from the Klaviyo App Marketplace.
Before you start
- An API key. Create one on the API keys page with the permission "Validate addresses". A daily credit cap limits what a busy flow can spend.
- A Klaviyo private API key. In Klaviyo: Settings → API keys → Create Private API Key → Custom scopes, with Profiles: Read (for the sync script) and Profiles: Write. Klaviyo does not let you change a key's scopes later.
- Two-step authentication on your Klaviyo login, which Klaviyo requires for webhook actions in flows.
- A Cloudflare account for the relay, and Node 22.18 or later for the sync script.
- The files, which are built from our tested code:
- relay.js: the flow-webhook relay, one Cloudflare Worker module;
- sync.mjs: the sync script, one Node module;
- flow-webhook.json: the webhook action's settings, for reference.
What is saved on the profile
Four custom profile properties, which Klaviyo creates on the first write:
email_validation_status valid, invalid, catch_all, unknown or disposable
email_validation_reason why, such as accepted or no_mailbox
email_validation_action send, send_with_caution, remove or retry_later
email_validation_checked_at when the check ran (ISO 8601, UTC)The status is one of the five result statuses. To leave out every profile you should not mail, build a segment on the action property equal to remove: spam traps and complainers are valid with the action remove.
Check new profiles with a flow
Deploy the relay to your Cloudflare account and give it its secrets:
npx wrangler deploy relay.js --name ev-klaviyo-relay --compatibility-date 2026-10-01 npx wrangler secret put EV_API_KEY --name ev-klaviyo-relay # your API key npx wrangler secret put KLAVIYO_API_KEY --name ev-klaviyo-relay # the Klaviyo private key npx wrangler secret put RELAY_SECRET --name ev-klaviyo-relay # 32 or more random charactersFor the relay secret, use for example the output of
openssl rand -hex 32. Your keys stay in the Worker's secret store. For EU data residency, add--var EV_REGION:euto the deploy command.In Klaviyo, create a flow with the trigger Added to List for your signup list, or any trigger that starts for each new profile. Add the flow filter "email_validation_status is not set", so a profile that enters the flow again is not checked, and charged, again.
Add a Webhook action:
Destination URL: the relay's address, such as
https://ev-klaviyo-relay.<your-subdomain>.workers.dev/. It must be HTTPS and must not redirect.Headers:
X-Relay-Secretwith the same value as the relay secret.JSON body:
{ "email": "{{ email }}" }
The relay finds the profile by its email. If your trigger's preview shows the profile's Klaviyo ID as a variable, you can add it to the body as the key profile_id; the relay then updates exactly that profile.
Turn the flow on, on a test list first.
Check it once
Add a test profile to the test list with the address no_mailbox@sandbox.email-validation.org, a test address that costs nothing, and give it one custom property of your own. Keep it off lists that send mail: the test domain receives none, so a message to it would bounce. When the flow has run, the profile's status property is invalid, its reason no_mailbox, and your own property is unchanged. Then delete the test profile.
How the relay answers Klaviyo
Klaviyo looks only at a webhook's HTTP status. It delivers again after a 429 or 5xx for about 24 hours, and puts any other failure on the flow's Skipped list.
| Situation | Relay answers | What to do |
|---|---|---|
| Saved | 200 | Nothing |
| The profile has no email (for example an SMS-only profile) | 200, skipped | Nothing: nothing was checked or charged |
| A rate limit, outage or timeout at our API | 503 | Nothing: Klaviyo delivers again |
| A relay setting is missing, or the relay secret is under 32 characters | 500 | Fix the secret; Klaviyo keeps trying meanwhile |
Wrong or missing X-Relay-Secret | 401 | Check the webhook's header |
| A body without the email key, or over 16 KB | 400 | Check the webhook's JSON body |
| The key was refused, no credits, or the key's daily cap | 422 with the error code | Fix the cause, then run the sync script for that period |
| The check ran but Klaviyo refused the write | 422 | Run the sync script for that period |
A failed Klaviyo write is never answered with 503, because a new delivery would pay for the check again. A 503 delivery that Klaviyo repeats checks the address again, and a repeated check uses a credit like the first one. The relay's answers never contain the address or a key, and the relay logs nothing.
Check existing profiles
export EV_API_KEY=… KLAVIYO_API_KEY=…
node sync.mjs --since 2026-11-01T00:00:00Z --dry-run # counts what it would check
node sync.mjs --since 2026-11-01T00:00:00Z # checks and savesThe script checks every profile created after --since that has an email and no status yet. Options: --region eu (or the variable EV_REGION=eu), --revalidate to check profiles that already have a status again, --limit N, and --concurrency N (4 by default).
- Rate limits and outages get up to four tries per call, after the wait the service asks for (at most 60 seconds). A check that got no answer at all is not sent again, because it may already have used a credit: the profile still has no status, so the next run picks it up.
- The run stops at once if our API refuses the key, the credits run out or the key reaches its daily cap, or if Klaviyo refuses its key.
- It prints profile IDs and statuses, never addresses. The exit code is
0when every check succeeded,1when a check failed or the run stopped early, and2when the arguments were wrong.
On a schedule, such as a cron job, set --since to a little before the previous run started.
Costs and limits
- Each checked profile is one check. Results with the status
unknownare free, and so are malformed addresses and the test addresses. - The flow filter on the status property keeps a profile that enters the flow again from being checked twice.
- Each sync run reads profiles through Klaviyo's API, which has its own rate limits; the script waits when Klaviyo asks it to.