Webflow Form Email Verification: Stop Fake and Mistyped Addresses
Webflow forms do not verify email addresses. The email field checks format, and reCAPTCHA checks for bots, but a real visitor who types jane@compnay.com gets through both. To catch fake and mistyped addresses, add a server-side check with an email verification API. There are two ways to wire it in. Pattern A listens to Webflow's form_submission webhook and verifies each address after it arrives. Pattern B checks the address before the form submits and asks the visitor to fix it. Both keep your API key off the page.
This guide covers what Webflow does on its own, then gives copy-ready code for both patterns as a single Cloudflare Worker. The examples use SimpleVerifier's API. Any verification API with a status field works the same way.
What Webflow forms catch, and what they miss
| Problem | Webflow email field | reCAPTCHA (native) | Email verification API |
|---|---|---|---|
jane@company (no TLD) |
Caught by format check | No | Caught |
| Bot filling the form | No | Mostly caught | No (not its job) |
jane@compnay.com (typo domain) |
No | No | Caught: no mail server |
jane.smth@gmail.com (mailbox does not exist) |
No | No | Caught: mailbox not found |
| Throwaway inbox | No | No | Caught: disposable address |
info@company.com |
No | No | Flagged risky: role-based |
Webflow's reCAPTCHA integration page describes the native option: reCAPTCHA v2, the "I'm not a robot" checkbox, set up in Site settings > Apps & Integrations. Two details matter. Once enabled, "Webflow enforces it across all forms on your site", and "any form that doesn't include a reCAPTCHA element will fail to submit". And "native reCAPTCHA only works when Webflow handles form submissions". If you point a form's Action at another service, it stops firing.
So spam tools and verification solve different problems. Use both. For the bot side, see our guides to honeypot fields and stopping fake signups. This guide is about the addresses real people type wrong, or make up.
Choosing a pattern
| Pattern A: webhook after submit | Pattern B: check before submit | |
|---|---|---|
| Visitor sees | Normal success message | A "please check your email" message on bad addresses |
| Webflow submission storage | Unchanged | Unchanged |
| Changes to the form | None | A small script on the page |
| Blocks bad addresses | No, flags them | Yes, invalid ones |
| Catches the typo while the visitor is still there | No | Yes |
| Best for | CRM hygiene, lead routing, newsletter imports | Demo requests, quotes, gated content |
Many teams run both. Pattern B asks the visitor to fix obvious mistakes. Pattern A records a status on every lead that gets through, for routing.
Pattern A: verify each submission through the webhook
How the webhook works
Webflow sends a form_submission event to your URL each time a form is submitted. The event reference lists the payload fields: the form name, siteId, the submitted data, a schema of fields, submittedAt, id, formId and formElementId. Webflow's webhook guide shows a sample where data holds the fields by name:
{
"triggerType": "form_submission",
"payload": {
"name": "Contact Us",
"siteId": "65427cf400e02b306eaa049c",
"data": {
"First Name": "Zaphod",
"Last Name": "Beeblebrox",
"email": "zaphod@heartofgold.ai"
}
}
}
The same guide sets the rules your endpoint must follow:
- Return 200. Any non-200 response, redirect, SSL problem or timeout counts as a failure.
- Retries. Webflow retries up to three more times at 10-minute intervals. Repeated failures deactivate the webhook, and you need Webflow support to reactivate it.
- Signatures. API-created webhooks carry
x-webflow-timestampandx-webflow-signature, a SHA-256 HMAC oftimestamp + ":" + body. Webhooks created in the dashboard "will not include the request headers needed to validate request signatures". - Replay window. Treat requests older than 5 minutes as suspect.
The verification call can take a few seconds while the recipient's mail server answers. So the function below returns 200 at once and does the work in the background.
Create the webhook through the API
With a site token that has the forms:read scope, call the Create Webhook endpoint as shown in Webflow's guide:
curl -X POST "https://api.webflow.com/sites/YOUR_SITE_ID/webhooks" \
-H "Authorization: Bearer YOUR_SITE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"triggerType": "form_submission",
"url": "https://verify.yourdomain.com/webflow-webhook"
}'
The guide's sample response includes a secretKey for webhooks made with a site token. Save it. That is the HMAC secret. The guide also mentions an optional filter to limit the webhook to one form by name, which helps on sites with many forms.
Pattern B: check the address before the form submits
The browser cannot call the verification API directly. Anyone can read a key from a page's code and spend your plan. Instead, the page calls a small endpoint you own, and that endpoint calls the API with the key held server-side. The endpoint returns only status and reason.
The page script holds the submit, asks your endpoint, and then either shows a message or lets Webflow submit as normal. Webflow's own handler, storage and reCAPTCHA keep working because the form's Action never changes.
The Worker: both patterns in one file
Deploy this as a Cloudflare Worker on a route such as verify.yourdomain.com. Add the secrets with npx wrangler secret put <KEY>, as described in Cloudflare's secrets docs:
| Secret | Value |
|---|---|
SIMPLEVERIFIER_KEY |
sv_live_... |
WEBFLOW_WEBHOOK_SECRET |
The secretKey from the webhook response |
RESULT_WEBHOOK_URL |
Where results go: a Make, Zapier or n8n catch hook, or your CRM's inbound endpoint |
ALLOWED_ORIGIN |
https://www.yourdomain.com |
const API = "https://simpleverifier.com/api/v1/verify";
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (url.pathname === "/webflow-webhook" && request.method === "POST") {
return handleWebhook(request, env, ctx);
}
if (url.pathname === "/check") return handleCheck(request, env);
return new Response("Not found", { status: 404 });
},
};
// Calls the verification API. Never throws; returns { status, reason }.
async function verify(email, env) {
try {
const res = await fetch(API, {
method: "POST",
headers: {
Authorization: `Bearer ${env.SIMPLEVERIFIER_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
signal: AbortSignal.timeout(20000),
});
const body = await res.json().catch(() => ({}));
if (res.ok && body.status) return { status: body.status, reason: body.reason ?? "" };
return { status: "error", reason: `HTTP ${res.status}` };
} catch {
return { status: "error", reason: "Timed out or unreachable" };
}
}
// Pattern A: Webflow form_submission webhook.
async function handleWebhook(request, env, ctx) {
const raw = await request.text();
if (!(await signatureOk(request, raw, env.WEBFLOW_WEBHOOK_SECRET))) {
return new Response("Bad signature", { status: 401 });
}
const { payload } = JSON.parse(raw);
const data = payload?.data ?? {};
const field = Object.keys(data).find((k) => k.toLowerCase().includes("email"));
const email = String(field ? data[field] : "").trim().toLowerCase();
ctx.waitUntil((async () => {
const result = email ? await verify(email, env) : { status: "skipped", reason: "No email field" };
await fetch(env.RESULT_WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ form: payload?.name, submissionId: payload?.id, email, ...result, data }),
});
})());
return new Response("ok", { status: 200 });
}
async function signatureOk(request, raw, secret) {
const ts = request.headers.get("x-webflow-timestamp");
const sig = request.headers.get("x-webflow-signature");
if (!ts || !sig || !secret) return false;
const tsMs = Number(ts) < 1e12 ? Number(ts) * 1000 : Number(ts); // accept seconds or ms
if (!Number.isFinite(tsMs) || Math.abs(Date.now() - tsMs) > 5 * 60 * 1000) return false;
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]
);
const mac = await crypto.subtle.sign("HMAC", key, enc.encode(`${ts}:${raw}`));
const hex = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("");
if (hex.length !== sig.length) return false;
let diff = 0;
for (let i = 0; i < hex.length; i++) diff |= hex.charCodeAt(i) ^ sig.charCodeAt(i);
return diff === 0;
}
// Pattern B: called by the page before submit.
async function handleCheck(request, env) {
const cors = {
"Access-Control-Allow-Origin": env.ALLOWED_ORIGIN,
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
};
if (request.method === "OPTIONS") return new Response(null, { headers: cors });
if (request.method !== "POST" || request.headers.get("Origin") !== env.ALLOWED_ORIGIN) {
return new Response("Forbidden", { status: 403, headers: cors });
}
const { email } = await request.json().catch(() => ({}));
const clean = String(email ?? "").trim().toLowerCase();
const result = clean ? await verify(clean, env) : { status: "invalid", reason: "Invalid format" };
return Response.json(result, { headers: cors });
}
Notes on the code
ctx.waitUntillets the Worker keep running after it returns 200 to Webflow. Cloudflare's context docs give HTTP-triggered Workers 30 seconds for this background work. That is why the API call aborts at 20 seconds and recordserrorinstead of hanging.- The signature is checked against the raw body, read once with
request.text(), before any parsing. - The email field is found by name. Webflow sends fields under their form names, such as
emailorEmail. Rename the match if your field is called something else, such as "Work address". /checkonly answers your own origin. TheOrigincheck stops other sites from using your endpoint in a browser. It does not stop a determined script, so watch usage, and add Cloudflare rate limiting rules if the endpoint is ever abused. The API itself allows 120 calls a minute per key.- POST with a JSON body is the same call as
GET ?email=. Our API docs list both and every reason string.
The page script for Pattern B
Add this to the page's custom code, before the closing body tag. Check that your Webflow site plan allows custom code. Set the form's ID to lead-form in the Designer.
<script>
(function () {
var form = document.getElementById("lead-form");
if (!form) return;
var input = form.querySelector('input[type="email"]');
var note = document.createElement("p");
note.setAttribute("role", "alert");
note.style.color = "#b42318";
input.insertAdjacentElement("afterend", note);
var cleared = false;
form.addEventListener("submit", async function (e) {
if (cleared) { cleared = false; return; } // let Webflow submit as normal
e.preventDefault();
e.stopImmediatePropagation();
note.textContent = "";
var result = { status: "error" };
try {
var res = await fetch("https://verify.yourdomain.com/check", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: input.value }),
signal: AbortSignal.timeout(8000)
});
result = await res.json();
} catch (err) { /* fail open */ }
if (result.status === "invalid") {
note.textContent = "That email address doesn't seem to work (" + result.reason +
"). Please check it and try again.";
input.focus();
return;
}
cleared = true;
form.requestSubmit();
}, true);
})();
</script>
How it behaves:
- The listener runs in the capture phase and stops the event, so the check happens before Webflow's own submit handling.
- Only
invalidblocks the form.riskyand any error pass through. Pattern A can still flag them later. - It waits 8 seconds at most, then submits anyway. A slow mail server should never cost you a lead.
- After a pass,
requestSubmit()sends the form again. The browser re-runs the field checks, per MDN's reference, and this time the script steps aside.
Webflow does not document the internals of its form script, so test on the staging domain first. Submit a good address and a bad one, and confirm both the message and Webflow's success state.
For more on timing, field feedback and latency on live forms, see real-time email verification on signup forms.
Worked example: a B2B demo request form
An agency site has a Webflow form called "Book a demo" with fields Name, Company and Email. Leads go to a CRM through a Make scenario. They deploy the Worker and run both patterns.
Four visitors submit (illustrative):
| Typed email | /check result | Visitor sees | Webhook result in CRM |
|---|---|---|---|
| ana@brightpath.co | valid, Mailbox exists | Success | email_status = valid |
| ben@brightpath.cmo | invalid, No mail server | "Please check it" | Corrects to .com; a valid row arrives |
| test123@mailinator.com | invalid, Disposable address | "Please check it" | Never submitted |
| sales@northwind.io | risky, Role-based address | Success | email_status = risky; routed to a rep, not the sequence |
The CRM ends up with one lead fixed by the visitor, one junk submission that never arrived, and a status on every lead for routing. Our lead form use case covers routing rules in more depth.
Handling each status
| Status | Meaning | Before submit (Pattern B) | After submit (Pattern A) |
|---|---|---|---|
| valid | Mail server confirmed the mailbox | Allow | Route normally |
| risky | Catch-all, role address, provider hides status, or server would not confirm | Allow | Tag; keep out of automated cold sequences |
| invalid | Bad format, no mail server, disposable, or mailbox not found | Block with a message | Suppress; do not mail |
| error | Check did not finish | Allow (fail open) | Re-check later |
If you want to block throwaway domains as well, our guide to blocking disposable emails covers the trade-offs.
Testing checklist
- Submit an address you own. Expect valid and a normal success message.
- Submit a made-up mailbox at gmail.com. Expect Pattern B to block it with "Mailbox not found".
- Submit with the Worker stopped. The form should still submit after the timeout.
- Send a request to
/webflow-webhookwithout signature headers. Expect 401. - Check your result destination for a row with
status,reasonand the submission ID.
Common problems
| Symptom | Likely cause | Fix |
|---|---|---|
| Every webhook returns 401 | Webhook created in the dashboard, so it is unsigned | Recreate it through the API and use its secretKey |
| Webhook deactivated | Endpoint returned non-200 or timed out repeatedly | Return 200 first; do work in waitUntil |
/check blocked by CORS |
ALLOWED_ORIGIN does not match the live domain exactly |
Include the scheme and www if used |
| Form submits without a check | Webflow's handler ran first | Load the script after the form; confirm the form ID |
HTTP 402 in results |
Subscription not active | API access needs Pro or Email + Phone |
What it costs
On SimpleVerifier, API access comes with Pro at $29.99 a month, with no per-call credits, or Email + Phone at $49.99 a month if your forms also collect phone numbers. If you only want to clean exported form submissions now and then, pay-as-you-go packs cost $9 for 2,500 or $19 for 10,000 verifications by CSV upload. See pricing.
The short version
Webflow checks format and bots, not mailboxes. To catch fake and mistyped emails, verify on a server. Subscribe to the form_submission webhook through the API, check the signature, return 200 at once and verify in the background. To catch typos while the visitor is still on the page, call your own endpoint before submit, block only invalid, and fail open on errors. Keep the API key in the Worker, never in the page.
Common questions
Can Webflow verify that an email address in a form is real?
No. A Webflow email field checks that the value looks like an address, and reCAPTCHA checks that a human sent it. Neither contacts the recipient's mail server. To confirm the mailbox exists, send the address to an email verification API from a server, either after submission through a webhook or before submission through your own endpoint.
Does reCAPTCHA stop fake emails in Webflow forms?
It stops many bots, not fake or mistyped addresses. A real person who types jane@compnay.com passes reCAPTCHA. Webflow's own integration page also notes native reCAPTCHA only works when Webflow handles form submissions, not with a custom action.
Why aren't my Webflow webhook requests signed?
Webflow's webhook guide says webhooks created through the dashboard do not include the headers needed to validate signatures. Create the form_submission webhook through the API with a site token. The response includes a secret key you use to check the x-webflow-signature header.
Can I call the verification API directly from Webflow custom code?
No. Anything in custom code is visible to visitors, including an API key. Put the key in a server-side function, such as a Cloudflare Worker, and have the page call that function. The function returns only the status and reason.
What happens if my webhook endpoint is down?
Webflow treats any non-200 response, redirect, SSL problem or timeout as a failure and retries up to three more times at 10-minute intervals. Repeated failures can deactivate the webhook. Return 200 quickly and do the verification in the background.
Verify unlimited addresses for $29.99/month
Real SMTP mailbox checks. No credits, no per-email fees.
Get Started