How to add GDPR-compliant opt-in checkboxes for Webflow forms

How to add GDPR-compliant email opt-in checkboxes to Webflow forms

Learn how to add an unticked consent checkbox to a Webflow form and enforce it in a Webflow Cloud route.

How to add GDPR-compliant email opt-in checkboxes to Webflow forms

Ismail Ajagbe
Technical Author
View author profile
Ismail Ajagbe
Technical Author
View author profile
Table of contents

An unticked checkbox, checked again on the server and confirmed by double opt-in, gives every newsletter signup a dated record of the wording the subscriber agreed to.

A newsletter form can fail on consent in two ways: a box ticked by default, or one box that bundles the terms with marketing email. Either way, when someone asks for proof, the only answer is that the address is on the list.

In this build, we keep the form in the Designer with the box unticked and add a small Route Handler on Webflow Cloud, on the same domain. It refuses any signup without consent, then adds the address to Mailchimp as pending, so a confirmation email completes double opt-in and the consent version and time are saved on the subscriber.

What do you need to add a GDPR opt-in checkbox to a Webflow form?

You need seven prerequisites before you begin:

  • Webflow site: A site with a form you control in the Designer, on Premium or higher if the app mounts on your custom domain.
  • Node.js and npm: A Node.js version supported by Webflow Cloud and npm, the only package manager Webflow Cloud supports.
  • Next.js 15 or higher: The minimum version in Webflow Cloud's bring your own app docs.
  • Code repository: A repository holding the app.
  • Mailchimp audience and API key: The audience ID, an API key, and three text merge fields named CONSENTV, CONSENTAT and CONSENTPG for the consent record. Another provider works if you swap the request in step 3 for its own add-or-update call.
  • Consent wording: Final label text agreed with your privacy lead, because the version string in code must match what the visitor reads.
  • Privacy policy URL: A published policy page to link from the checkbox label, so visitors can see how their data is described.

If your site still shows the old CMS or Business plan name, both were folded into Premium.

With those in hand, the build relies on one decision you make in the Designer and then enforce in code: the checkbox starts unticked and carries its own label.

How to add a GDPR-compliant opt-in checkbox in Webflow Cloud

The build is a Designer form whose consent checkbox starts unticked, plus a Route Handler on Webflow Cloud that refuses any submission where that value is false and stores a versioned consent record on the Mailchimp subscriber.

What sets the handler apart on Webflow Cloud is what you leave out, because the app runs on Cloudflare Workers rather than Node. The route carries no runtime directive, and the config sets no base path. Consent records go to your email provider instead of the filesystem.

1. Add the consent checkbox to the form in the Designer

Give the form a stable ID and three named fields before you touch any code. Open the site in the Webflow Designer, select the newsletter form, and set its ID to newsletter-form. The email input keeps the field name email.

Add a checkbox with the field name marketing_consent, and confirm it is unchecked by default; a box that loads ticked only records the page default, so the record cannot show the visitor chose it.

Then add one more text input named hp_field, hide it with a display setting, and give it the custom attributes autocomplete="off" and tabindex="-1" so browsers and keyboard users skip it. That hidden field is a honeypot, and the Route Handler treats anything that fills it as a bot. Avoid names like company that autofill fills in, or real visitors can get dropped.

Drafting the checkbox label and privacy link

Write the label as a standalone sentence that says what the visitor is agreeing to, how often, and where the privacy policy is. Something like "Send me the monthly product newsletter by email.

You can unsubscribe at any time; see our privacy policy." I keep the privacy link inside the label itself so the purpose and the policy link sit on the same line the visitor reads.

Do not fold this into a terms-of-service acceptance. On a newsletter-only form, marketing email is the only thing being offered, so the Route Handler refuses an unticked submission. If you reuse the form for a download or registration, split the marketing box off so the other purpose works without it.

Match the ids and field names exactly so the script and handler can find them. When you preview the page, the checkbox should render unticked with its full label beside it, and the hp_field input should be invisible.

2. Scaffold the Next.js app with npm

Start with the standard Next.js scaffold and nothing else. Webflow Cloud supports Next.js 15 or higher, and its Next.js customization docs say you don't need to add an adapter, a base path, or an output mode, so start from the default project.

Use npm throughout; the platform does not support pnpm or yarn, so keep only the npm lockfile in the repository.

From a terminal in your project's folder, run:

npx create-next-app@15 consent-gate --typescript --app --eslint --no-tailwind --no-src-dir --import-alias "@/*"
cd consent-gate
npm run dev

That gives you a running app with the App Router and a package-lock.json.

I leave two things deliberately absent. There is no export const runtime = 'edge' anywhere, because Webflow Cloud deploys Next.js through the OpenNext Cloudflare adapter, and that adapter does not support the Next.js edge runtime target.

And there is no basePath in next.config, because the mount path is injected at build time by the platform. Resist adding either, even though Webflow's current bring-your-own-app page still suggests the edge directive.

At the end of this step, npm run dev should serve the default Next.js page on localhost with no changes to configuration.

3. Write the consent Route Handler

I built the handler so only a valid email with explicit consent and an empty honeypot reaches your provider. Create app/api/consent/route.ts. The handler checks the request origin against an allowed value, parses the JSON body, rejects anything where marketing_consent is not an explicit true or "on", and only then assembles a consent record and forwards it.

The record carries the label-wording version string and an ISO timestamp, plus the page the visitor was on, which together tie each subscriber to a wording version and a moment in time. It is stored as merge fields on the subscriber's Mailchimp profile, inside your own account.

The handler, in full:

import { createHash } from "node:crypto";
import { NextResponse } from "next/server";

// Bump this whenever the checkbox label text changes, and keep the old wording on record.
const CONSENT_VERSION = "marketing-v1";

type Payload = {
  email?: string;
  marketing_consent?: string | boolean;
  hp_field?: string; // honeypot, must stay empty
  page?: string;
};

export async function POST(request: Request) {
  const allowedOrigin = process.env.ALLOWED_ORIGIN;
  const origin = request.headers.get("origin") ?? "";
  if (!allowedOrigin || origin !== allowedOrigin) {
    return NextResponse.json({ error: "Forbidden" }, { status: 403 });
  }

  let body: Payload;
  try {
    body = (await request.json()) as Payload;
  } catch {
    return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
  }

  if (body.hp_field) {
    // Honeypot filled: treat as a bot and return a quiet success.
    return NextResponse.json({ ok: true });
  }

  const email = (body.email ?? "").trim().toLowerCase();
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    return NextResponse.json(
      { error: "A valid email address is required" },
      { status: 400 }
    );
  }

  const consented =
    body.marketing_consent === true ||
    body.marketing_consent === "on" ||
    body.marketing_consent === "true";

  if (!consented) {
    return NextResponse.json(
      { error: "Marketing consent was not given" },
      { status: 400 }
    );
  }

  const apiKey = process.env.MAILCHIMP_API_KEY;
  const listId = process.env.MAILCHIMP_LIST_ID;
  if (!apiKey || !listId) {
    console.error("consent_route_misconfigured", {
      hasKey: Boolean(apiKey),
      hasList: Boolean(listId),
    });
    return NextResponse.json({ error: "Server misconfigured" }, { status: 500 });
  }

  // A Mailchimp key ends in its data center, for example "-us21".
  const dataCenter = apiKey.split("-").pop();
  // Mailchimp identifies a subscriber by the MD5 hash of the lowercase email.
  const subscriberHash = createHash("md5").update(email).digest("hex");
  const consentedAt = new Date().toISOString();

  const mailchimpResponse = await fetch(
    `https://${dataCenter}.api.mailchimp.com/3.0/lists/${listId}/members/${subscriberHash}`,
    {
      method: "PUT", // add or update, so a returning subscriber is not an error
      headers: {
        "Content-Type": "application/json",
        Authorization: `Basic ${btoa(`anystring:${apiKey}`)}`,
      },
      body: JSON.stringify({
        email_address: email,
        status_if_new: "pending", // Mailchimp emails a confirmation link: double opt-in
        merge_fields: {
          CONSENTV: CONSENT_VERSION,
          CONSENTAT: consentedAt,
          CONSENTPG: (body.page ?? "").slice(0, 255),
        },
      }),
    }
  );

  if (!mailchimpResponse.ok) {
    console.error("mailchimp_error", mailchimpResponse.status, CONSENT_VERSION);
    return NextResponse.json({ error: "Subscription failed" }, { status: 502 });
  }

  console.log("consent_recorded", CONSENT_VERSION, consentedAt);
  return NextResponse.json({ ok: true, consent_version: CONSENT_VERSION });
}

You now have a route that returns 400 for any unticked box, 403 for a foreign origin, and 502 if Mailchimp refuses the subscriber.

The explicit misconfiguration check makes a missing variable easy to identify by returning a clear 500 response. We store the record on the subscriber rather than in a file because the Workers runtime that Webflow Cloud deploys to doesn't expose node:fs; node:path is available, but the filesystem isn't.

The logs carry only the consent version and timestamp, keeping email addresses out of them because an email address in platform logs is one more copy of personal data to account for. The origin check is a cross-site request guard rather than authentication, and it refuses every request when ALLOWED_ORIGIN is unset.

Formatting Mailchimp API credentials and double opt-in

The Mailchimp call follows the add-or-update request that Webflow's Mailchimp integration page documents: HTTP Basic auth with the API key as the password, and the subscriber addressed by the MD5 hash of the lowercase email, which node:crypto computes both under next dev and on Webflow Cloud.

Because it is a PUT, a returning subscriber is updated rather than rejected, and status_if_new: "pending" makes Mailchimp send its confirmation email, so the address joins the list only when its owner clicks.

The origin check and honeypot are not authentication: a script can forge the Origin header and submit any address. Double opt-in keeps a forged address off the list, but each forged request still sends that person a confirmation email, so add a rate limit before launch. The API key stays server-side; nothing secret uses a NEXT_PUBLIC_ prefix.

Testing the consent handler locally with environment variables

Create a .env.local file in the project root so the origin check passes locally:

ALLOWED_ORIGIN=http://localhost:3000
MAILCHIMP_API_KEY=your-api-key-us21
MAILCHIMP_LIST_ID=your-audience-id

With those three values in place, the handler passes its origin and configuration checks on localhost, though the Mailchimp call only succeeds once the key and audience ID are real.

Restart npm run dev, then send an unticked request with a matching Origin header:

curl -s -X POST http://localhost:3000/api/consent \
  -H "Content-Type: application/json" \
  -H "Origin: http://localhost:3000" \
  -d '{"email":"test@example.com","marketing_consent":false}'

The response should be a 400 with the message "Marketing consent was not given." A 403 means the Origin header and ALLOWED_ORIGIN do not match.

4. Point the Designer form at the Route Handler

I intercept the form's submit event and send the fields as JSON to the mounted route. The Designer form and the Webflow Cloud app share a domain once the app is mounted, so a relative path works, and because the request is same-origin, there should be no CORS to configure. The path just has to include the mount path, which is /signup for this app.

The platform does not rewrite client-side requests, so you write the full mounted path yourself. Add a text element inside the form with the attribute data-consent-message so the script has somewhere to write.

Add a Code Embed element to the page, below the form, and paste the script. Intercepting the submit has a cost: the form never reaches Webflow's own handler, so Webflow no longer stores the submission or sends its notification email.

Adding the submit handler script to Webflow

Drop this into the Code Embed element:

<script>
  document.addEventListener("DOMContentLoaded", function () {
    var form = document.getElementById("newsletter-form");
    if (!form) return;

    form.addEventListener("submit", async function (event) {
      event.preventDefault();
      event.stopImmediatePropagation();
      var data = new FormData(form);
      var payload = {
        email: data.get("email"),
        marketing_consent: data.get("marketing_consent") === "on",
        hp_field: data.get("hp_field") || "",
        page: window.location.href
      };

      var response = await fetch("/signup/api/consent", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(payload)
      });

      var result = {};
      try { result = await response.json(); } catch (e) { result = {}; }
      var message = form.querySelector("[data-consent-message]");
      if (!message) return;
      if (response.ok) {
        message.textContent = "You're subscribed. Check your inbox.";
        form.reset();
      } else {
        message.textContent = result.error || "Something went wrong.";
      }
    }, true);
  });
</script>

The form now posts to your handler instead of submitting natively, and the visitor sees the server's exact refusal message when the box is left unticked.

The capture-phase listener and stopImmediatePropagation are there to stop other submit listeners on the form from also acting on the submission. If the response is not JSON, such as a 404 page, the message falls back to "Something went wrong."

This script lives in the Designer, outside the Next.js app, so it hardcodes /signup. If you later add a page inside the app itself that calls the same route, that page's fetch should read the mount path from NEXT_PUBLIC_BASE_PATH rather than hardcode it, with that variable set to the mount path in the Webflow Cloud environment, since the platform does not create it for you.

Verifying form submission and mount paths

For now, publish the page and submit the form once on the origin you plan to test. The page should not reload, and the network panel should show a POST to /signup/api/consent that carries the mount path.

That request returns 404 until you mount the app in Webflow Cloud, so hold the form test until the app is mounted. Once the app is mounted, a test returns 403 unless ALLOWED_ORIGIN matches the published origin, so test the refusal and success paths from the domain you set it to.

5. Set the environment variables, mount the app, and test both branches

I push the consent-gate project to its repository before creating the Webflow Cloud project and environment for the site. Connect that repository and set the mount path to /signup, following the Webflow Cloud setup docs linked in the prerequisites. If the mount is on your custom domain, the site needs Premium or higher.

In that environment, add three variables before the first deploy: MAILCHIMP_API_KEY as a secret, MAILCHIMP_LIST_ID as plain text, and ALLOWED_ORIGIN set to your site's origin.

Webflow Cloud makes both secret and non-secret variables available to the build and to the deployed app at runtime, and redacts secrets from build logs, so the key never appears in output. Then deploy the app and publish the Designer page to the custom domain.

Testing refusal and success responses in production

With ALLOWED_ORIGIN set to the published origin in your shell, two requests exercise the refusal path and the success path:

# Unticked box: expect 400 and "Marketing consent was not given"
curl -s -X POST "$ALLOWED_ORIGIN/signup/api/consent" \
  -H "Content-Type: application/json" \
  -H "Origin: $ALLOWED_ORIGIN" \
  -d "{\"email\":\"test@example.com\",\"marketing_consent\":false,\"page\":\"$ALLOWED_ORIGIN/\"}"

# Ticked box: expect 200 and the consent_version string
curl -s -X POST "$ALLOWED_ORIGIN/signup/api/consent" \
  -H "Content-Type: application/json" \
  -H "Origin: $ALLOWED_ORIGIN" \
  -d "{\"email\":\"test@example.com\",\"marketing_consent\":true,\"page\":\"$ALLOWED_ORIGIN/\"}"

If both requests return the expected responses, the deployed route matches local behavior.

Check Mailchimp after the second request. The address should appear as pending, with CONSENTV, CONSENTAT and CONSENTPG filled in, and a confirmation email should reach the test inbox; clicking it turns the status to subscribed.

Then submit the real form in a browser with the checkbox ticked and confirm the same record lands. The /signup/api/consent request in the network panel should now return 400 or 200 instead of 404.

The run is complete when an unticked submission from the live page shows the refusal message, a ticked one shows the success message, and Mailchimp shows a subscriber carrying marketing-v1 as the consent version.

What causes a GDPR opt-in checkbox to fail in Webflow forms?

The troubleshooting steps below cover four categories: a runtime directive copied from the docs, a fetch that misses the mount path, a filesystem call the Workers runtime does not have, and a secret set in the wrong environment.

Use these four categories to isolate the cause.

Webflow Cloud deploy fails for a route that runs locally

Cause: Webflow Cloud deploys Next.js through the OpenNext Cloudflare adapter, and that adapter does not support the Next.js edge runtime target. The confusion is a naming collision. Webflow Cloud runs on Cloudflare Workers, which is an edge platform, and the docs describe it that way.

The Next.js directive export const runtime = 'edge' selects a separate runtime target inside Next.js, and OpenNext cannot build it. Webflow's own bring-your-own-app page still tells you to add the directive to API routes, so a developer who adds it is following the docs faithfully and producing a build OpenNext cannot complete.

Fix: I remove the line from app/api/consent/route.ts and from any other route where it was added, then redeployed. The handler already runs on Workers without any directive; the platform decides where the code executes, not the export.

The failure surfaces in the Webflow Cloud deploy. Keep a note in the repository that the directive is unsupported, since the next person to read the docs page will try to add it back.

The form works under npm run dev, but the live site returns a 404 for the consent route

Cause: The Route Handler is deployed and healthy, but the fetch is addressing /api/consent without the mount path, so it is not where the browser is looking. Locally, the app sits at the root of localhost, so the bare path resolves.

On Webflow Cloud, the app is mounted under a path such as /signup, and client-side fetch calls are not rewritten to include it; the docs state plainly that client-side fetch calls must manually include the base path.

This shows up most often when someone copies the fetch from a local test or from an app page that forgot NEXT_PUBLIC_BASE_PATH. Checking the mounted URL first isolates the issue quickly because the route works locally.

Fix: In the Designer embed, I make the URL /signup/api/consent, matching the mount path set in the environment exactly, including case.

For any fetch inside the Next.js app itself, set NEXT_PUBLIC_BASE_PATH to the mount path in the Webflow Cloud environment and build the URL from it, rather than reading basePath from next.config, which the platform no longer expects you to set.

After republishing the Designer page, open the browser's network panel on submit; the request URL should carry the mount path and return 400 or 200 rather than 404.

The Webflow Cloud deploy or first request fails once the route imports node:fs

Cause: The deployed app runs on Cloudflare Workers, not Node, and node:fs is not available at the compatibility date Webflow Cloud currently pins.

Nothing about the import is syntactically wrong, which is what makes it confusing; node:path imports fine, so a developer reasonably assumes the rest of the node: namespace follows. It does not. Sending the consent record over HTTP fits this runtime, while a consent log that appends JSON lines to a file doesn't work, whether the issue surfaces at build or runtime.

Fix: I keep the record off the local filesystem. The handler in this build stores it as merge fields on the Mailchimp subscriber, which is where you will want it when someone asks for proof anyway.

If you switch to a provider that cannot store custom fields, POST the record to an external store over HTTP from the same handler, and keep the console.log line so the platform logs carry the version and timestamp.

Remove the node:fs import and any fs.appendFile call, then redeploy; the deploy should succeed, and Mailchimp should show the consent fields on the next ticked submission.

Mailchimp returns 401 from the deployed route but works locally

Cause: The deployed handler is sending an API key Mailchimp rejects. Environment variables on Webflow Cloud are set per environment, so a key that lives in your local .env.local file is not present in the deployed environment unless you added it there under the same name. Secrets are redacted from build logs, so the value does not appear in the log.

A 401 means the key reached the handler, but Mailchimp rejected it. A missing or misspelled variable name, or one added to a different environment, never gets that far: the handler's misconfiguration check returns 500 "Server misconfigured" instead.

I check the 500 versus 401 distinction first, because it tells me whether the problem is the name or the value. A missing ALLOWED_ORIGIN shows up differently: the handler returns 403 for every request rather than 401.

Fix: Open the environment the app is actually deployed from and confirm MAILCHIMP_API_KEY and MAILCHIMP_LIST_ID exist with those exact names. Variables are available at both build and runtime; redeploy after adding them.

If the names match and the 401 persists, the key itself is wrong or belongs to another Mailchimp account; rotate it, paste it into the secret field fresh, and rerun the ticked curl request.

A 400 instead of a 401 usually means one of the three merge fields does not exist in the audience. A 200 with the consent_version in the body confirms the credential path end to end.

What you can build next with consent forms and Webflow

I use the same versioned-consent pattern anywhere a visitor grants permission, including event registrations and gated downloads where the marketing box is a separate, optional field.

The same Webflow Cloud app could also serve a preference center page where a subscriber can see which version they agreed to and withdraw it. The submit-intercept pattern also validates phone numbers on a Webflow form before a lead reaches your systems.

Marketers keep editing and publishing the form in the Designer, and any change to the label wording ships together with a CONSENT_VERSION bump in the Route Handler so records never point at the wrong text.

For deeper customization beyond what a native form handles, Webflow's developer docs cover Webflow Cloud environments and what the Workers runtime does and does not provide.

Frequently asked questions

Should I mark the consent checkbox as required in the Designer?

On this newsletter-only form, yes: the browser flags the empty box before any request is sent, and the route still refuses an unticked submission. Never make it required on a form that does anything else, such as a download or registration, because consent made a condition of the form is not freely given.

Do I need double opt-in as well?

This build already uses it: status_if_new: "pending" makes Mailchimp email a confirmation link. Mailchimp's own double opt-in setting covers only its signup forms, so an API signup needs that field. GDPR doesn't require double opt-in, but in some countries, including Germany, it is the usual way to prove consent.

Where do I keep the text for each CONSENT_VERSION?

I store each label wording alongside its version string, either in the repository or in a Webflow CMS collection, so a version on a subscriber can be matched to the exact sentence they saw. When the label changes, bump CONSENT_VERSION in the Route Handler and ship the label change at the same time. Never edit an old entry.

How does an unsubscribe relate to the consent record?

I treat the consent fields as history while the email provider's unsubscribe handles withdrawal, so the visitor leaves the list without touching the Route Handler. The subscriber fields show when and under which version they opted in. How long you keep that history after an unsubscribe is a retention decision for whoever owns privacy on your team.


Last Updated
October 9, 2026
Category

Related articles


verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logosoundcloud logoreddit logothe new york times logoideo logoupwork logodiscord logo
verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logosoundcloud logoreddit logothe new york times logoideo logoupwork logodiscord logo

Get started for free

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.

Get started — it’s free
Watch demo

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.