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:
- sync.mjs: the sync command, one Node module;
- relay.js: the webhook relay, one Cloudflare Worker module;
- webhook.json: the relay's webhook settings, for Mailchimp's API;
- app.js and app-wrangler.jsonc: the OAuth app and its Worker settings.
The tags
| Tag | Meaning |
|---|---|
ev:valid, ev:invalid, ev:catch_all, ev:unknown, ev:disposable | the result status; a member has exactly one, and a new check replaces the old one |
ev:remove | the 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 removeIt 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
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-relayRELAY_SECRETneeds at least 32 random letters and digits, for example fromopenssl rand -hex 32. For EU data residency, add--var EV_REGION:euto the deploy command. To check only one audience, also setMAILCHIMP_LIST_IDto its id.In Mailchimp, open Audience → Settings → Webhooks → Create New Webhook:
- Callback URL the relay's address followed by
/and yourRELAY_SECRET, such ashttps://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, toPOST /3.0/lists/<audience id>/webhooks. If you turn on signing for the webhook, put its signing secret inMAILCHIMP_WEBHOOK_SECRETand the relay refuses any delivery without a valid signature.- Callback URL the relay's address followed by
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 tagsev:invalidandev: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.
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 ashttps://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.Deploy the app. In the folder with
app.jsandapp-wrangler.jsonc:npx wrangler kv namespace create ev-mailchimp-tokensPut the id it prints into
app-wrangler.jsonc, and setAPP_URLthere 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.jsoncAPP_SETUP_SECRETopens the app's pages: at least 32 random letters and digits (openssl rand -hex 32).TOKEN_ENCRYPTION_KEYencrypts 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, setEV_REGIONtoeuinapp-wrangler.jsonc.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.
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 answer | When | What to do |
|---|---|---|
200 saved | The member was checked and tagged | Nothing |
200 ignored or skipped | Not a subscribe event, another audience, the member is gone, or it already has a status tag | Nothing |
200 not saved | The check ran, but Mailchimp did not take the tags | Run the sync command to catch up |
503 | A rate limit or an outage before the check | Nothing: Mailchimp delivers again |
422 | Our API refused the key, there are no credits or the key reached its daily cap; or Mailchimp refused the key or token | Fix the cause, then run the sync command to catch up |
404 | The secret in the address is wrong | Check 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
unknownare 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
--concurrencyat 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.