Verify Email Addresses in a Cloudflare Worker Before Your Form Saves Them
To verify emails before a form saves them, put a Cloudflare Worker in front of your save step. The page posts the address to the Worker, the Worker calls the email verification API with your key held as a secret, and it answers the page with a simple verdict: block do_not_send, warn on risky, accept the rest. If the check cannot run (402, 429, 503 or a timeout) the Worker lets the visitor through and says so, because a verification outage should never cost you a signup.
This guide builds that Worker against the SimpleVerifier API. It is useful when your form lives on a static site, a page builder or a no-code tool and you have no backend. If your app already has a server, see the Next.js signup guide or the Express guide, and there is a shorter Cloudflare Worker recipe in the signup protection section.
What you need
- A SimpleVerifier plan with API access: Pro, Business or Agency. Pro is $39 a month and includes 10,000 API calls a month, then $1.50 per 1,000 on monthly billing. Pro email verification also runs the first 100,000 checks a month at full speed, then slower, never cut off. See pricing.
- An API key from the API keys page, starting
sv_live_, shown once. - A Cloudflare account and Node.js, to use Wrangler.
Step 1: Create the Worker
npm create cloudflare@latest verify-email-worker
cd verify-email-worker
Pick a plain JavaScript "Hello World" Worker when the wizard asks. Open wrangler.toml (or wrangler.jsonc) and keep the name and compatibility date it generated.
Step 2: Add the key as a secret
npx wrangler secret put SIMPLEVERIFIER_KEY
Wrangler prompts for the value. Paste your key. Cloudflare's docs say this creates a new version of the Worker and deploys it immediately. For local development, put the key in a .dev.vars file next to your Wrangler config, using dotenv syntax, and add .dev.vars* to .gitignore:
SIMPLEVERIFIER_KEY="sv_live_xxx"
You can also add a secret in the dashboard: Workers & Pages, your Worker, Settings, Variables and Secrets, Add, choose type Secret, then Deploy.
In a module Worker you read it as env.SIMPLEVERIFIER_KEY. It never reaches the browser.
Step 3: The Worker code
Replace src/index.js.
const ENDPOINT = "https://simpleverifier.com/api/v1/verify";
const ALLOWED_ORIGIN = "https://www.your-site.com"; // your form's origin
const CHECK_TIMEOUT_MS = 12000; // the page should not wait the full 30 seconds
const corsHeaders = {
"Access-Control-Allow-Origin": ALLOWED_ORIGIN,
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
};
const reply = (body, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json", ...corsHeaders },
});
export default {
async fetch(request, env, ctx) {
if (request.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
if (request.method !== "POST") return reply({ error: "POST only" }, 405);
let email = "";
try {
const body = await request.json();
email = String(body.email ?? "").trim().toLowerCase();
} catch {
return reply({ error: "Send JSON with an email field" }, 400);
}
if (email.length > 320 || !email.includes("@")) {
return reply({ ok: false, decision: "invalid", message: "Please enter a valid email address." });
}
let response;
try {
response = await fetch(`${ENDPOINT}?email=${encodeURIComponent(email)}`, {
headers: { Authorization: `Bearer ${env.SIMPLEVERIFIER_KEY}` },
signal: AbortSignal.timeout(CHECK_TIMEOUT_MS),
});
} catch {
return reply({ ok: true, decision: "unchecked", message: "" });
}
if (response.status === 401 || response.status === 402) {
console.error(`SimpleVerifier answered ${response.status}: fix the key or the plan`);
return reply({ ok: true, decision: "unchecked", message: "" });
}
if (response.status === 429 || response.status >= 500) {
return reply({ ok: true, decision: "unchecked", message: "" });
}
if (!response.ok) {
return reply({ ok: true, decision: "unchecked", message: "" });
}
const result = await response.json();
if (result.action === "do_not_send") {
return reply({
ok: false,
decision: "invalid",
message: "That address can't receive email. Please check it for typos.",
});
}
if (result.action === "send_with_care") {
return reply({ ok: true, decision: "risky", message: "" });
}
if (result.action === "retry_later") {
return reply({ ok: true, decision: "unchecked", message: "" });
}
return reply({ ok: true, decision: "valid", message: "" });
},
};
How the decisions are chosen:
- Block only
do_not_send. Theactionfield is the machine form of the verdict, with a fixed shape.invalidalways givesdo_not_send, and onlyvalidever givessend. Reasons for invalid include a bad format, no mail server, a typo domain, a disposable address and a mailbox that was not found, all listed in the API docs. send_with_careis accepted and tagged risky. That covers catch-all domains, role inboxes and providers that hide mailbox status. Do not block real people who happen to use a role address.retry_lateris accepted as unchecked. It means the mail server did not answer our check, which says nothing about the visitor.- The key stays in the Worker. The reply to the page carries only
ok,decisionand a message, not the raw API response.
The two kinds of failure are handled differently on purpose. Network errors, timeouts, 429 and 5xx all mean the check did not happen, so the visitor passes and the address is marked unchecked. A 401 or 402 also passes the visitor, but it logs loudly, because those need a fix on your side: a revoked key, or a plan with no API access or a used-up yearly quota. See errors.
Why the Worker uses its own 12 second timeout
A live mailbox check waits on the recipient's mail server and can take up to about 30 seconds when that server is slow. Cloudflare's documentation says time spent waiting on fetch does not count toward CPU time, so a long wait does not hit the Worker's CPU limit (10 ms on Free, up to 5 minutes on paid plans). But a person staring at a spinner will not wait 30 seconds. Twelve seconds covers most checks. A timeout is not a verdict, so the visitor passes with unchecked.
Step 4: Call it from the form
<form id="signup">
<input type="email" name="email" required autocomplete="email" />
<p id="signup-note" role="alert"></p>
<button type="submit">Join</button>
</form>
<script>
const WORKER_URL = "https://verify-email-worker.YOUR-SUBDOMAIN.workers.dev";
const form = document.getElementById("signup");
const note = document.getElementById("signup-note");
form.addEventListener("submit", async (event) => {
event.preventDefault();
note.textContent = "";
const email = form.elements.email.value;
let verdict = { ok: true, decision: "unchecked" };
try {
const response = await fetch(WORKER_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
verdict = await response.json();
} catch {}
if (!verdict.ok) {
note.textContent = verdict.message;
return;
}
// Save the lead with verdict.decision as a field: valid, risky or unchecked.
// Replace this with your own save call or form action.
console.log("save", email, verdict.decision);
});
</script>
Two things to notice. The page never sees the verification key, only the Worker's small reply. And when the Worker or the network fails, verdict stays { ok: true, decision: "unchecked" }, so the form still saves.
Your save step is the part this guide leaves to you: a form backend, a CRM API call, a database insert. Whatever it is, store the decision next to the address.
Step 5: Deploy and test
npx wrangler deploy
Then test with curl:
curl -X POST "https://verify-email-worker.YOUR-SUBDOMAIN.workers.dev" \
-H "Content-Type: application/json" \
-d '{"email": "jane@company.com"}'
Test these cases: a good address (expect valid), one with a misspelled domain such as jane@gmial.com (expect it to be blocked), a role address like info@ on a real company (expect risky, accepted), and a deliberately wrong key in .dev.vars (expect unchecked and an error line in the logs).
Protect your own allowance
A public endpoint that calls a paid API can be abused. Every request is an API call on your plan. Three habits:
- Keep
ALLOWED_ORIGINset to your own site. CORS stops other websites' scripts from reading the answer, though it does not stop someone calling the Worker directly. - Add a honeypot or a bot check to the form so bots do not reach the Worker. See honeypot fields for signup bots.
- Add rate limiting in front of the Worker using Cloudflare's own tools, and watch your usage on the API keys page.
One API key can make 120 calls in a sliding minute. Past that the API answers 429 with a Retry-After header. The Worker above treats that as unchecked and moves on; if you see it often, your form is under load or being hit by bots.
Limits to know
| Limit | Value | Source |
|---|---|---|
| API calls per key | 120 per minute | API docs |
| Slowest mailbox check | About 30 seconds | Quickstart |
| Subrequests per invocation | 50 on Free, 10,000 on paid Workers | Cloudflare limits page |
| Worker CPU per request | 10 ms Free, up to 5 minutes paid | Cloudflare limits page |
The Worker makes one subrequest per submission, so these limits are not close.
What it costs
Each form submission is one API call. Pro includes 10,000 calls a month, then $1.50 per 1,000 on monthly billing. On yearly billing the included calls are a fixed monthly quota, and further calls return 402 until the 1st (UTC), which this Worker handles by letting visitors through. Business and Agency include more. Cloudflare Workers has its own plans and limits.
The short version
Store SIMPLEVERIFIER_KEY with wrangler secret put, post the email to a Worker, and let the Worker decide: block do_not_send, tag risky, accept everything the check could not answer. Use a shorter timeout than the API's 30 seconds, keep the origin locked, and protect the endpoint from bots. The reference lives in the API docs and the quickstart.
Common questions
Why not call the verification API from the browser?
Because the key would be visible to anyone who opens the page, and they could use your plan. The API docs say to keep keys on your server and call from your backend. A Worker is a small backend you do not have to host.
Does waiting for the verification API use up my Worker's CPU time?
Cloudflare's documentation says time spent waiting on network requests such as fetch does not count toward CPU time, which measures only time executing your code. Free Workers have 10 ms of CPU per request, and the code in this guide uses very little.
What should the form do if the API is down or rate limited?
Let the visitor through. A broken check should never lose a signup. Save the address with a flag such as unverified, and verify it later in a batch.
How many subrequests does the Worker make?
One per form submission, to the verification API. Cloudflare's limits page lists 50 subrequests per invocation on the Free plan and 10,000 on paid plans.
Should I block risky addresses?
Usually not at the form. Risky means delivery was not confirmed: a catch-all domain, a role address, a provider that hides mailbox status, or a server that did not answer. Accept them, tag them, and keep them out of cold sequences.
Check your list before you send
Real mailbox checks over SMTP, and nothing is sent to the address. Sign in with Google and your first 100 email checks are free, no card. Plans for bigger lists are on the pricing page.