Validate emails in Mailchimp

Check the addresses of a Mailchimp audience and tag each member with the result, so your segments and journeys can leave out the addresses you should not mail. Three small pieces do the work:

  • A sync command checks every member of an audience that has no result yet, in one bulk list.
  • A webhook relay checks each new subscriber within seconds. It signs in to Mailchimp with an API key.
  • An OAuth app does what the relay does, but signs in to Mailchimp with OAuth and adds the webhooks itself. You connect your Mailchimp account on its page instead of copying a key and a webhook address.

Use the sync command for the first clean-up, with either the relay or the app for new subscribers. All of them run in your own accounts with your own keys: the command with Node, the relay and the app as Cloudflare Workers. The app is not in the Mailchimp Marketplace yet, so you run it yourself.

Before you start

  • An API key. Create one on the API keys page. The relay and the app need the permission "Validate addresses"; the sync command also needs "Bulk lists", which is off by default. A daily credit cap limits what a busy audience can spend.
  • Your audience id. In Mailchimp: Audience → Settings → Audience name and defaults → Audience ID, such as a6b5da1054.
  • Node 22.18 or later for the sync command, and a Cloudflare account for the relay or the app.
  • The files, which are built from our tested code:

The tags

TagMeaning
ev:valid, ev:invalid, ev:catch_all, ev:unknown, ev:disposablethe result status; a member has exactly one, and a new check replaces the old one
ev:removethe action is remove: do not mail this member

Spam traps and complainers are ev:valid with ev:remove, so build your segment on ev:remove: Tags → contact is tagged → ev:remove holds every member you should not mail. Tags need no setup, and subscribers never see them. Adding a tag starts any Customer Journey that begins with "tag added" for that tag: build journeys on our tags on purpose, or start them from a segment instead.

Clean an audience

The sync command needs your Mailchimp API key: Profile → Extras → API keys → Create A Key. It ends in your data center, such as -us21, and the command sends requests only to that data center's API. The key can do everything in your account, so keep it secret.

export EV_API_KEY=…  MAILCHIMP_API_KEY=…
node sync.mjs --list a6b5da1054 --dry-run    # counts what it would check
node sync.mjs --list a6b5da1054              # checks and tags
node sync.mjs --list a6b5da1054 --suppress   # also archives the members whose action is remove

It lists the subscribed members, skips those that already have a status tag other than ev:unknown (add --revalidate to check them again), checks the rest in one bulk list and tags each member. It prints member ids and counts, never addresses.

If a run stops for any reason, such as no credits or a closed laptop, run the same command again. It keeps the bulk job's id in a small state file (ids only, no addresses) and resumes that job, so a list is never paid for twice. Exit code 75 means the job was still running when the command stopped waiting: run it again later.

--suppress archives each member whose action is remove: the member leaves the audience and stops counting toward your plan, but keeps its data and history, and you can add it back. Nothing is ever deleted permanently.

Other options: --status subscribed,pending for other member statuses, --region eu for EU data residency, --concurrency for Mailchimp writes at once (default 5, at most 10), and --wait-minutes for how long to wait for a bulk list (default 240).

Check new subscribers with the relay

  1. Deploy the relay to your Cloudflare account and give it its secrets:

    npx wrangler deploy relay.js --name ev-mailchimp-relay --compatibility-date 2026-10-01
    npx wrangler secret put EV_API_KEY --name ev-mailchimp-relay
    npx wrangler secret put MAILCHIMP_API_KEY --name ev-mailchimp-relay
    npx wrangler secret put RELAY_SECRET --name ev-mailchimp-relay

    RELAY_SECRET needs at least 32 random letters and digits, for example from openssl rand -hex 32. For EU data residency, add --var EV_REGION:eu to the deploy command. To check only one audience, also set MAILCHIMP_LIST_ID to its id.

  2. In Mailchimp, open Audience → Settings → Webhooks → Create New Webhook:

    • Callback URL the relay's address followed by / and your RELAY_SECRET, such as https://ev-mailchimp-relay.<your-subdomain>.workers.dev/<RELAY_SECRET>;
    • Events Subscribes only; Sources all three.

    To create the webhook with Mailchimp's API instead, send webhook.json, with your URL filled in, to POST /3.0/lists/<audience id>/webhooks. If you turn on signing for the webhook, put its signing secret in MAILCHIMP_WEBHOOK_SECRET and the relay refuses any delivery without a valid signature.

  3. Add a test contact with the address no_mailbox@sandbox.email-validation.org, a test address that costs nothing and receives no mail. Within a few seconds it has the tags ev:invalid and ev:remove. Then archive the test contact, and keep it out of campaigns until then.

The secret in the URL is what proves a delivery comes from your webhook, so anyone who can read that URL can send deliveries to the relay. Keep request logs that record URLs off for the Worker, and change the secret if it leaks.

Check new subscribers with the OAuth app

The app is the relay with a sign-in to Mailchimp through OAuth, for one or more Mailchimp accounts. It stores each account's token encrypted in a KV namespace of your own Cloudflare account, and only you can open its pages.

  1. Register an OAuth app in Mailchimp: Profile → Extras → Registered apps → Register an app. As the redirect URI, give your app's address followed by /oauth/callback, such as https://ev-mailchimp-app.<your-subdomain>.workers.dev/oauth/callback. Mailchimp then shows the client id and the client secret; copy the secret right away, as it is shown once.

  2. Deploy the app. In the folder with app.js and app-wrangler.jsonc:

    npx wrangler kv namespace create ev-mailchimp-tokens

    Put the id it prints into app-wrangler.jsonc, and set APP_URL there to your app's address. Then:

    npx wrangler deploy -c app-wrangler.jsonc
    npx wrangler secret put EV_API_KEY -c app-wrangler.jsonc
    npx wrangler secret put MAILCHIMP_CLIENT_ID -c app-wrangler.jsonc
    npx wrangler secret put MAILCHIMP_CLIENT_SECRET -c app-wrangler.jsonc
    npx wrangler secret put APP_SETUP_SECRET -c app-wrangler.jsonc
    npx wrangler secret put TOKEN_ENCRYPTION_KEY -c app-wrangler.jsonc

    APP_SETUP_SECRET opens the app's pages: at least 32 random letters and digits (openssl rand -hex 32). TOKEN_ENCRYPTION_KEY encrypts the stored tokens: 32 random bytes (openssl rand -base64 32). Keep it: without it the app cannot read its connections, and you connect again. For EU data residency, set EV_REGION to eu in app-wrangler.jsonc.

  3. Connect. Open the app's address, type the setup secret, choose Connect a Mailchimp account and allow access in Mailchimp. Back on the app's page, choose Check new subscribers next to an audience. The app adds the audience's webhook itself, with a secret address of its own.

  4. Check it once with the test contact no_mailbox@sandbox.email-validation.org, as in step 3 of the relay.

Stop checking removes an audience's webhook. Disconnect this account removes the webhooks and deletes the token from your KV namespace; Mailchimp keeps the app's access until you remove it in your Mailchimp account too. The app's pages ask for the setup secret again after an hour.

Answers and retries

Mailchimp cancels a delivery that takes more than 10 seconds, and delivers a failed one again for about 75 minutes. The relay and the app answer the same way:

They answerWhenWhat to do
200 savedThe member was checked and taggedNothing
200 ignored or skippedNot a subscribe event, another audience, the member is gone, or it already has a status tagNothing
200 not savedThe check ran, but Mailchimp did not take the tagsRun the sync command to catch up
503A rate limit or an outage before the checkNothing: Mailchimp delivers again
422Our API refused the key, there are no credits or the key reached its daily cap; or Mailchimp refused the key or tokenFix the cause, then run the sync command to catch up
404The secret in the address is wrongCheck the webhook's address

Nothing is charged for a delivery that answers 503 or 422. Before it checks, the relay reads the member and skips one that already has a status tag, so a repeated delivery costs nothing.

Costs and limits

  • Each checked address is one check. Results with the status unknown are free, and so are malformed addresses, duplicates in a list and the test addresses.
  • The relay and the app give a check at most 5 seconds, to answer within Mailchimp's 10.
  • Mailchimp allows 10 connections at once per user, across all your apps: keep --concurrency at or below that.
  • Our own rate limits apply as to any other client.

Privacy

The pieces send each address only to our API, for the check, and to Mailchimp's API for that member's tags. Their answers and output never contain an address, a key or a token, and the relay and the app write no logs. The sync command's state file holds job ids only. The app stores, per connected Mailchimp account, its token, account name and watched audiences, encrypted, in your own KV namespace; it stores no addresses or results.

Updated · Maintained by the email-validation.org team