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

  1. 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 characters

    For 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:eu to the deploy command.

  2. 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.

  3. 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-Secret with 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.

  4. 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.

SituationRelay answersWhat to do
Saved200Nothing
The profile has no email (for example an SMS-only profile)200, skippedNothing: nothing was checked or charged
A rate limit, outage or timeout at our API503Nothing: Klaviyo delivers again
A relay setting is missing, or the relay secret is under 32 characters500Fix the secret; Klaviyo keeps trying meanwhile
Wrong or missing X-Relay-Secret401Check the webhook's header
A body without the email key, or over 16 KB400Check the webhook's JSON body
The key was refused, no credits, or the key's daily cap422 with the error codeFix the cause, then run the sync script for that period
The check ran but Klaviyo refused the write422Run 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 saves

The 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 0 when every check succeeded, 1 when a check failed or the run stopped early, and 2 when 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 unknown are 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.

Updated · Maintained by the email-validation.org team