A Webflow Cloud route can subscribe each form signup to an ActiveCampaign list the moment it arrives, so your automation starts without anyone copying emails by hand.
If your nurture sequences run in ActiveCampaign, the slow part is the handoff. A signup lands in a form notification, someone copies the email into a list, and the automation starts hours late. A notification is only a record. A contact subscribed to a list triggers your automation.
ActiveCampaign's Webflow app maps form fields to lists and tags without code, and it is the right start for most teams. When you want spam filtering or custom logic in code you control, one Next.js Route Handler on Webflow Cloud turns each form post into a subscribed contact under a path on your own domain.
What do you need to connect ActiveCampaign to Webflow forms in Webflow Cloud?
You need these five prerequisites:
- Webflow site: A form on a published page, plus a published thank-you page and a published error page for failed signups.
- ActiveCampaign account: A list to subscribe contacts to and an automation whose trigger is a subscription to that list.
- Local toolchain: Webflow Cloud supports Next.js 15 or higher and, in its words, "only the npm package manager."
- Repository: A code repository a Webflow Cloud project can deploy from, with push access for whoever maintains the site after launch.
- Credentials: The API URL and API key from Settings under the Developer tab in ActiveCampaign, and the numeric list ID, stored in a password manager and never in the repository.
Pick the mount path for the app now; the form's action has to include it, and so does NEXT_PUBLIC_BASE_PATH if a script ever calls the route with fetch.
5 steps to connect ActiveCampaign to Webflow forms in Webflow Cloud
The build is a single Route Handler plus six environment variables. The form stays a native Webflow form; only its action changes, so a marketer can still edit the fields in the Designer.
Credentials come first, and the live test comes last, so you read the handler before anything points at it.
1. Collect the ActiveCampaign credentials and list ID
Copy three values out of ActiveCampaign before you open a terminal. The API URL and key are under Settings, on the Developer tab; the URL looks like https://youraccount.api-us1.com. The list ID is the number in the list's URL when you open it.
I check the automation side at the same time. Open the automation you want to fire and confirm its trigger is Subscribes to a list, naming the list whose ID you copied. The handler subscribes a contact to that list and lets ActiveCampaign's own trigger start the automation, which keeps the automation editable by a marketer without touching code.
You now have the API URL and key plus a list ID in a password manager, and an automation whose trigger points at that list.
2. Scaffold the Next.js app and create the Webflow Cloud project
Use Node.js 22 or later with npm to create a fresh Next.js app and keep the defaults, including the App Router, because Route Handlers live there.
Run this from the folder that will hold the repository. The version is pinned to 15 so the scaffold matches what Webflow Cloud supports:
npx create-next-app@15 activecampaign-bridge
cd activecampaign-bridge
I leave the config alone. Webflow Cloud's configuration docs state that "You don't need to add an adapter, a base path, or an output mode," and Webflow Cloud sets basePath at build time from the mount path. Do not add export const runtime = 'edge' to anything.
Webflow Cloud runs on Cloudflare Workers, an edge platform, while the Next.js edge runtime target is a separate thing that the OpenNext Cloudflare adapter does not support, so the directive ships a broken build even though one docs page still tells you to add it.
Push the repository to a host your Webflow Cloud project can deploy from. Then create the project and set a mount path such as /subscribe, following the setup flow in the bring-your-own-app docs linked in the prerequisites.
Write the mount path down; the form's action depends on it. Webflow Cloud builds with npm only, so skip any pnpm add or yarn add steps from other tutorials. When the first deploy finishes, open the mount path on your staging domain; the default Next.js starter page should load.
3. Write the Route Handler that forwards submissions to ActiveCampaign
Add one file at app/api/contact/route.ts. It uses two endpoints from ActiveCampaign's v3 API, both authenticated with an Api-Token header: contact sync, which creates or updates a contact by email, and contact lists, which subscribes that contact to your list.
The handler parses the form body and drops bot submissions through a honeypot. It then validates the email, syncs and subscribes the contact, and answers with a redirect when a browser submitted the form natively or with JSON when a script did.
The handler authenticates outward with your API key, which stays in a server-side environment variable, but it does not authenticate the caller. The origin check and honeypot filter casual abuse, so add a rate limit or a CAPTCHA check before launch if the form is public.
Handler code
Here’s the full handler:
// app/api/contact/route.ts
import { NextResponse } from 'next/server';
type Contact = { email: string; firstName: string };
type AcConfig = { AC_BASE_URL: string; AC_API_KEY: string; AC_LIST_ID: string };
function getConfig() {
return {
AC_BASE_URL: process.env.ACTIVECAMPAIGN_API_URL,
AC_API_KEY: process.env.ACTIVECAMPAIGN_API_KEY,
AC_LIST_ID: process.env.ACTIVECAMPAIGN_LIST_ID,
SITE_ORIGIN: process.env.SITE_ORIGIN,
THANK_YOU_URL: process.env.THANK_YOU_URL,
ERROR_URL: process.env.ERROR_URL,
};
}
export async function POST(request: Request) {
const { AC_BASE_URL, AC_API_KEY, AC_LIST_ID, SITE_ORIGIN, THANK_YOU_URL, ERROR_URL } =
getConfig();
if (!AC_BASE_URL || !AC_API_KEY || !AC_LIST_ID) {
console.error('ActiveCampaign environment variables are missing');
return fail(request, 500, 'Server misconfigured', ERROR_URL);
}
const origin = request.headers.get('origin');
if (SITE_ORIGIN && origin && origin !== SITE_ORIGIN) {
return fail(request, 403, 'Forbidden', ERROR_URL);
}
let form: FormData;
try {
form = await request.formData();
} catch {
return fail(request, 400, 'Invalid form', ERROR_URL);
}
const email = String(form.get('email') ?? '').trim().toLowerCase();
const firstName = String(form.get('first-name') ?? '').trim();
const honeypot = String(form.get('website') ?? '').trim();
// Bots fill every field; humans never see this one.
if (honeypot) return respond(request, THANK_YOU_URL);
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return fail(request, 400, 'Invalid email', ERROR_URL);
}
const ac: AcConfig = { AC_BASE_URL, AC_API_KEY, AC_LIST_ID };
let synced: { ok: boolean; status: number };
try {
synced = await syncContact(ac, { email, firstName });
} catch (error) {
console.error('ActiveCampaign unreachable', error);
return fail(request, 502, 'Upstream error', ERROR_URL);
}
if (!synced.ok) {
console.error('ActiveCampaign rejected the contact', synced.status);
return fail(request, 502, 'Upstream error', ERROR_URL);
}
return respond(request, THANK_YOU_URL);
}
function respond(request: Request, thankYouUrl?: string) {
const wantsHtml = request.headers.get('accept')?.includes('text/html');
if (wantsHtml && thankYouUrl) {
return NextResponse.redirect(thankYouUrl, 303);
}
return NextResponse.json({ ok: true }, { status: 200 });
}
function fail(request: Request, status: number, error: string, errorUrl?: string) {
const wantsHtml = request.headers.get('accept')?.includes('text/html');
if (wantsHtml && errorUrl) {
return NextResponse.redirect(errorUrl, 303);
}
return NextResponse.json({ error }, { status });
}
async function activeCampaign(ac: AcConfig, path: string, body: unknown) {
return fetch(`${ac.AC_BASE_URL}${path}`, {
method: 'POST',
headers: {
'Api-Token': ac.AC_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(8000),
});
}
async function syncContact(ac: AcConfig, contact: Contact) {
const body = contact.firstName ? { contact } : { contact: { email: contact.email } };
const sync = await activeCampaign(ac, '/api/3/contact/sync', body);
if (!sync.ok) return { ok: false, status: sync.status };
const data = (await sync.json()) as { contact?: { id?: string } };
const contactId = data.contact?.id;
if (!contactId) return { ok: false, status: 502 };
const subscribe = await activeCampaign(ac, '/api/3/contactLists', {
contactList: { list: ac.AC_LIST_ID, contact: contactId, status: '1' },
});
return { ok: subscribe.ok, status: subscribe.status };
}
With that file committed, the app has a POST endpoint at /api/contact that will sit under your mount path once deployed.
A few choices in there deserve a reason. I return a quiet 200 to the honeypot on purpose; a 4xx tells a bot author what to fix.
The handler subscribes the contact after every sync instead of relying on the sync call for list membership, so a returning contact who re-submits still ends up on the list.
ActiveCampaign's list status reference warns that this re-subscribes a contact who had unsubscribed from that list, which is deliberate here because the visitor just opted in again.
The sync body leaves out a blank first name, so a returning contact who skips that field keeps the name already on file. The origin check only blocks cross-site browser posts; treat it as a minimum.
Run npm run dev and post to http://localhost:3000/api/contact with an email field; with no environment variables set, you should get the 500 "Server misconfigured" response, which proves the route is wired.
4. Set the environment variables in Webflow Cloud
Add the six variables to the Webflow Cloud project's environment rather than to a committed file. The environment variables docs state that "Both secret and non-secret environment variables are available to your application's build process and to the deployed application at runtime," and secrets are redacted from build logs, so the API key can be a secret without breaking the build.
Here is every variable the handler reads and where its value comes from:
| Variable | Value | Store as secret |
|---|---|---|
ACTIVECAMPAIGN_API_URL |
The API URL from Settings, Developer, such as https://youraccount.api-us1.com, with no /api/3 and no trailing slash |
No |
ACTIVECAMPAIGN_API_KEY |
Your ActiveCampaign API key | Yes |
ACTIVECAMPAIGN_LIST_ID |
The numeric ID of the list your automation watches | No |
SITE_ORIGIN |
The scheme and host the form is served from, e.g. https://yoursite.webflow.io while testing |
No |
THANK_YOU_URL |
The absolute URL of the published Webflow thank-you page | No |
ERROR_URL |
The absolute URL of a published Webflow page telling the visitor the signup failed and to try again | No |
| Variable → Value → Store as secret |
|---|
ACTIVECAMPAIGN_API_URL |
The API URL from Settings, Developer, such as https://youraccount.api-us1.com, with no /api/3 and no trailing slash |
| No |
ACTIVECAMPAIGN_API_KEY |
| Your ActiveCampaign API key |
| Yes |
ACTIVECAMPAIGN_LIST_ID |
| The numeric ID of the list your automation watches |
| No |
SITE_ORIGIN |
The scheme and host the form is served from, e.g. https://yoursite.webflow.io while testing |
| No |
THANK_YOU_URL |
| The absolute URL of the published Webflow thank-you page |
| No |
ERROR_URL |
| The absolute URL of a published Webflow page telling the visitor the signup failed and to try again |
| No |
A missing ActiveCampaign row returns the 500 response. A missing SITE_ORIGIN silently turns the origin check off, and a missing THANK_YOU_URL or ERROR_URL shows the visitor raw JSON, so set all six.
For local work, I put the same names in .env.local with the staging values. SITE_ORIGIN is the one that changes at launch: once the app is mounted on a custom domain (Premium or higher), update it to the production origin or every real submission is rejected with a 403 and sent to the error page. Redeploy after saving so you test against a build that has the new values.
After the redeploy, posting an email to the mounted route from a tool like curl with an Origin header matching SITE_ORIGIN returns { "ok": true }, and the contact appears in your ActiveCampaign list.
5. Point the Webflow form at the route and test the automation
I change where the form sends rather than rebuilding it. In the form's Settings panel, under Send to, replace the Webflow and Email notifications destinations with a Custom action.
Enter the full mounted path as the URL (for a /subscribe mount that is /subscribe/api/contact) and choose POST. The mount segment is the part that's easy to drop: the route lives under the mount path on the site's domain, not at the site root.
With the action pointed at your route, the route owns the submission. Webflow's form docs note that a custom action bypasses Webflow's form handling, so submissions are no longer stored in Webflow; the visitor lands on your thank-you or error page, and the record lives in ActiveCampaign.
Then line up the field names. The handler reads email, first-name, and website, so set each input's name so the rendered name attribute matches exactly; check the published page's HTML rather than trusting the label.
Add the website field as the honeypot and hide it with a class set to display: none. A real visitor never sees it, and a bot that fills it gets the quiet success response.
Testing the integration
Publish to the staging domain and submit a real email. Because the form posts natively, the browser follows the 303 redirect to the thank-you page; a rejected email or an ActiveCampaign failure redirects to the error page instead.
Then check that the contact is in the ActiveCampaign list and that the automation's first step is queued. If the automation sends an email, it arrives on its own schedule; the list subscription is the part you are testing here.
Once the automation fires on the staging domain, you have a working pipeline: form submit, Route Handler, ActiveCampaign contact, list subscription, automation.
What causes ActiveCampaign and Webflow Cloud form posts to fail?
Failures fall into four categories: the Next.js runtime directive, the mount path, ActiveCampaign credentials on the deployed project, and the automation's own trigger settings.
The first shows up at build time, and the other three appear only after you publish and submit the form.
The Webflow Cloud build fails on the Route Handler
Cause: One Webflow Cloud docs page still tells you to add the edge runtime directive to API routes. Following it faithfully ships a broken build, because Webflow Cloud deploys Next.js through the OpenNext Cloudflare adapter and that adapter does not support the Next.js edge runtime target.
The docs use "edge" for two different things on the same page: the Cloudflare Workers platform your app runs on, and the Next.js runtime target the directive selects. The platform sentence makes the directive look mandatory.
Fix: I start by deleting the line from route.ts and any other file that carries it, then redeploy. The handler already runs on Workers without it. Read any future docs line about the edge runtime as a description of where the app runs.
If you later add middleware, for example to move the origin check out of the route, it has a related limit: Next.js 16 renames middleware.ts to proxy.ts, and proxy runs on the Node.js runtime with no way to opt into the edge runtime.
Webflow Cloud's framework-customization page says "Node.js runtime middleware isn't supported. Only Edge runtime middleware works on the Workers runtime." Keep the file named middleware.ts on Webflow Cloud, even on Next.js 16.
The form works under next dev but returns 404 on the published site
Cause: Locally, the route answers at /api/contact; on Webflow Cloud it answers under the mount path, and a form action of /api/contact asks the site root for a path the app does not serve there. The basePath is injected at build time, but nothing rewrites what the browser requests.
The bring-your-own-app docs put it plainly: "Client-side fetch calls must manually include the base path." A native form action is also a browser request, so the same logic applies.
Fix: Set the custom action to the mounted path (/subscribe/api/contact for a /subscribe mount) and republish. If you later move submission into a script that calls fetch, add a NEXT_PUBLIC_BASE_PATH variable set to the mount path, since Webflow Cloud does not create it, and build the URL from it rather than hardcoding it.
Submissions land on the error page on the deployed site but work locally
Cause: A 502 means ActiveCampaign rejected or never answered the request, and the route logs the status it returned. The usual causes are a key pasted with a trailing space, or an API URL saved with /api/3 or a trailing slash, which breaks every request path.
A 429 means the account hit ActiveCampaign's rate limit of 5 requests per second, and each signup uses two. If a curl test against the deployed route returns 500 "Server misconfigured" instead, an ActiveCampaign variable is missing on the Webflow Cloud project, because .env.local never leaves your machine.
Fix: Trim whitespace from the key and save the API URL with no /api/3 and no trailing slash. Then re-add any missing variable on the Webflow Cloud project, with the key as a secret, and redeploy; secrets are redacted from build logs, so confirm the value by re-pasting it rather than by reading a log.
The contact appears on the ActiveCampaign list, but the automation never starts
Cause: The route did its job once the contact is on the list, so the issue is in the automation's settings. The automation may be inactive, or its trigger may name a different list than the ID in ACTIVECAMPAIGN_LIST_ID.
A contact who was already subscribed creates no new subscription, so the trigger does not fire for them, and a trigger set to run once skips anyone who already went through the sequence.
Fix: Activate the automation, then open its trigger and point it at the list your handler subscribes contacts to. Test with a fresh email address that has never been on the list.
If returning contacts should re-enter, open the trigger's options and switch Runs once to Runs multiple times; they re-enter only after they unsubscribe and subscribe again.
What you can build next with ActiveCampaign and Webflow
The same route shape works for a second form that tags contacts by campaign, or for a sign-up form that also checks the visitor's phone number, the way the phone validation guide does before any data leaves the site.
For a broader look at the integration from ActiveCampaign's side, their app spotlight covers what the native app adds beyond forms.
For customization past a single Route Handler, Webflow's developer docs cover Webflow Cloud's framework customization.
Frequently asked questions
Can I send more form fields to ActiveCampaign?
Yes. Read each extra field with a form.get() call keyed by the input's name attribute and add it to the object passed to syncContact(). Custom fields go in the sync body as fieldValues: [{ field: '<field ID>', value }] inside contact, where the field ID is the number ActiveCampaign assigns to each custom field. Then republish the Webflow form with the new input and test from staging.
What happens to a submission if ActiveCampaign is unavailable?
The handler returns a 502, and visitors who submit from the page are redirected to the ERROR_URL page, where they can go back and retry. The handler does not store the submission. The route has no automatic retry; if you need one, send failed submissions to a queue or data store you already run.
Can the automation trigger on a tag instead of a list subscription?
Yes. Create the tag in ActiveCampaign, set the automation's trigger to Tag is added, and in syncContact() swap the contact lists call for POST /api/3/contactTags with { contactTag: { contact: contactId, tag: TAG_ID } }. The tag value is the numeric tag ID, not its name, so store it in its own environment variable. To skip triggers entirely, POST /api/3/contactAutomations with { contactAutomation: { contact, automation } } adds a contact to an automation directly.
Does the origin check stop spam on its own?
No. A request with no Origin header passes the check because the condition includes origin &&, and a script running outside a browser can send any Origin value it likes. If the form draws abuse, add a rate limit at the top of POST, backed by a data store you already run, or verify a CAPTCHA token before syncContact() runs.





