You can connect every client's Webflow campaign page to HubSpot once, then launch the next one with a Collection item and a publish.
Two Webflow campaign pages can carry the same form, the same HubSpot portal, and the same field names and still land different data in the CRM. Each page was wired to HubSpot through whichever integration method was most convenient at launch.
Webflow prefixes generated form IDs with wf-form-, which is enough for HubSpot's form scraper to see one form as two and log every submission twice. UTM parameters that were on the ad's landing page are gone by the time the visitor reaches the form page, so the paid social spend attributes to nothing. Neither shows up as an error.
HubSpot's standard form embed renders inside an iframe, and scripts on the parent page cannot reach into it. That fact drives the architecture, because it causes both the styling that gets overwritten a second after publish and the UTM values that never populate the hidden fields.
Going through the Forms API from a Webflow Cloud route handler avoids both: the form stays a native Webflow form you style like any other element, and the campaign context is assembled server-side.
So what's worth building isn't another integration. It is one submission path that every campaign form on every client site shares, where launching campaign eleven is a Collection item, one script invocation, and a publish.
What do you need to build scalable client campaign workflows in Webflow with HubSpot?
You need a Webflow site with the CMS available and a Webflow Cloud app deployed to it; a HubSpot private app token with form and CRM scopes; HubSpot's tracking code live on the client site; and a Collection holding the per-campaign configuration.
Only the last one is per campaign. The token and the tracking code are one-time setup per HubSpot portal, which is what makes the second client faster than the first.
Here is what each piece has to look like before any code runs.
A Webflow site with the CMS and a Webflow Cloud app
The CMS makes this repeatable, and it isn't on every plan. Webflow's lineup changed in May 2026: the former CMS and Business plans merged into a single Premium Site plan, from $25 per month billed yearly, including 20,000 CMS items and 40 Collections.
The Basic plan has no CMS, so Premium is the practical floor. Mounting a Webflow Cloud app to a custom domain is also unavailable on Starter and Basic, which matters here because the tracking cookie and the campaign page need to share an origin. Current figures are on the Webflow plans and pricing page.
On the app side, you need Next.js 15 or later, Node.js 22 or later locally, and npm. Webflow Cloud supports only npm today, so a project on pnpm or Yarn needs its lockfile regenerated before the first deploy.
A HubSpot private app token with the right scopes
Server-side calls need a token, and it comes from a private app. HubSpot now lists these under Development, then Legacy apps, where you click Create legacy app, choose Private, name it on the Basic Info tab, then add scopes on the Scopes tab.
You have to be a super admin, and the token appears on the Auth tab behind Show token.
Four scopes cover the build:
formsfor the submission endpointscrm.objects.contacts.writeandcrm.objects.contacts.readfor upserting contacts directlycrm.schemas.contacts.readso you can confirm a custom property exists before writing to it
Campaign registration also needs marketing.campaigns.read and marketing.campaigns.write, both restricted to Marketing Hub Professional and Enterprise.
One caution worth designing around: HubSpot ties the token to the user who created it, and the docs say removing that user makes calls fail with USER_DOES_NOT_HAVE_PERMISSIONS. I create client private apps under a shared admin identity for exactly that reason.
HubSpot's tracking code installed on the client site
The tracking code sets the hubspotutk cookie, and without it, submissions arrive with no visitor history. Copy it from HubSpot under Tracking & Analytics, then Tracking code, in the Embed code section.
HubSpot's instruction is to paste it before the closing </body> tag, which in Webflow means Site settings, then the Custom code tab, into Footer code rather than Head code. Each field takes up to 50,000 characters, and nothing takes effect until the site is published.
Install exactly one. HubSpot is explicit that "if there are multiple HubSpot tracking codes installed, the first one to load on the page will fire," a real hazard on a site running both the HubSpot App and a hand-pasted snippet.
The cookie holds an opaque visitor GUID and expires in six months. Across a portfolio, pushing the snippet programmatically beats pasting it site by site, and the guide covers the custom code API.
A Campaigns Collection holding the per-campaign configuration
This is what turns a one-off integration into a workflow. Create a Collection from the CMS tab called Campaigns, with the fields the handler needs to route a submission: plain text for the HubSpot form GUID, plain text for the portal ID, plain text for the campaign's UTM value, and a switch for whether the campaign accepts submissions.
Two fields do the real work. The form GUID tells the handler which HubSpot form to submit to, so one handler can serve every client. The active switch lets the marketing team close a campaign without asking you to deploy.
Keep the GUIDs as plain text rather than rich text, because rich text arrives wrapped in HTML when you read it through the API.
7 steps to build a scalable Webflow Cloud to HubSpot campaign workflow
In HubSpot, you create the custom properties that carry campaign context onto the contact record. In Webflow, you build one Collection page template that renders every campaign and carries its HubSpot identifiers as CMS-bound attributes. In your Webflow Cloud app, you build the Forms API helper, the route handler, and the client script that connects them.
Work through them in order, because each step consumes a value the previous one produced.
1. Add the HubSpot credentials as Webflow Cloud environment variables
The private app token must never reach the browser, so put it in environment variables rather than a committed file. Open your app's environment in the Webflow Cloud dashboard, find the Environment Variables section, and use Add variable, then Add single variable, for each value.
If you already have them in a local .env, Bulk import takes the whole file at once, which is how I set up each new client environment:
HUBSPOT_PRIVATE_APP_TOKEN=pat-na1-00000000-0000-0000-0000-000000000000
HUBSPOT_PORTAL_ID=12345678
NEXT_PUBLIC_BASE_PATH=/campaigns
Mark HUBSPOT_PRIVATE_APP_TOKEN as a secret, which tells Webflow Cloud to mask it in the dashboard. HUBSPOT_PORTAL_ID is your Hub ID, found by clicking your account name in HubSpot's upper right and reading the value under Account.
NEXT_PUBLIC_BASE_PATH must match the app's mount path, and it carries the public prefix deliberately because the browser script needs it to build the fetch URL. A mount path is not a secret.
Variables reach both the build process and the deployed app at runtime, but they do not reach a deployment that already exists. Push a commit after any change. A token added after the last deploy returns a 401 with no other signal, which is confusing because the value looks correct in the dashboard.
2. Create the HubSpot custom properties the workflow writes to
HubSpot has no default properties for raw UTM values or for which agency client a contact belongs to, so create them before the first submission.
Doing it through the API means the same properties exist identically in every portal you onboard, which is the difference between a workflow and a set of similar setups:
// scripts/create-hubspot-properties.mjs
// Run once per HubSpot portal.
const TOKEN = process.env.HUBSPOT_PRIVATE_APP_TOKEN
const properties = [
{ name: 'campaign_slug', label: 'Campaign slug' },
{ name: 'agency_client', label: 'Agency client' },
{ name: 'utm_source_raw', label: 'UTM source (raw)' },
{ name: 'utm_medium_raw', label: 'UTM medium (raw)' },
]
for (const property of properties) {
const response = await fetch(
'https://api.hubapi.com/crm/properties/2026-03/contacts',
{
method: 'POST',
headers: {
Authorization: `Bearer ${TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
groupName: 'contactinformation',
name: property.name,
label: property.label,
type: 'string',
fieldType: 'text',
}),
}
)
console.log(property.name, response.status, await response.text())
}
The name value is the internal name, and every later API call references it. HubSpot states that you cannot edit a property's internal name after creation, so the lowercase-with-underscores convention in the script is worth sticking to, even though HubSpot documents it as a convention rather than a rule.
The type and fieldType pair sets behavior: string with text gives a single-line text field. Reusing contactinformation as groupName puts the properties where the marketing team will look.
Run it once per portal and read the status codes, because a property that already exists returns an error rather than silently succeeding. That is what you want when onboarding a client whose portal someone else configured.
3. Build the campaign page template on a CMS Collection page
Open the Campaigns Collection page and build the layout once: hero, offer copy, and a native Form Block. Bind the text elements to their Collection fields as usual. What makes this a workflow is the Form wrapper's custom attributes.
Select the Form wrapper, open the Element settings panel, find Custom attributes, and click the plus icon. Add data-hs-form-id, then click the purple dot in the Value field and connect it to the HubSpot form GUID field.
Repeat for data-hs-portal-id, data-campaign-slug, and data-agency-client. Webflow only allows CMS data on custom attributes on Collection pages or inside Collection lists, which is where this form lives:
<!-- Rendered output for one Collection item -->
<div class="campaign-form-wrapper"
data-hs-form-id="1a2b3c4d-5e6f-7890-abcd-ef1234567890"
data-hs-portal-id="12345678"
data-campaign-slug="q3-demand-gen-webinar"
data-agency-client="northwind-labs">
<form id="wf-form-Campaign-Form" data-name="Campaign Form">
<input type="email" name="email" data-hs-property="email" required>
<input type="text" name="firstname" data-hs-property="firstname">
<select name="company_size" data-hs-property="company_size">...</select>
<input type="submit" value="Get the guide">
</form>
</div>
Read that markup as a routing table, not a form. The four wrapper attributes tell the script which portal and form the submission belongs to, which campaign context to attach, and which value came from the Collection item.
The data-hs-property attribute on each input is the second half of the design: it names the HubSpot internal property the field maps to, so a new field is one attribute rather than a mapping configured in a dashboard. The select element sits there without ceremony, because the Forms API accepts any property type.
To reuse the layout across client sites, convert the sections to components using the method in the guide to landing page design system.
One limit is that Webflow's Libraries feature, which shares components across sites in a Workspace, is in beta, and components containing a Collection list are hidden when sharing, so a shared library carries your form styling but not its CMS bindings.
4. Build the HubSpot Forms API helper
With the page emitting its own routing data, the app needs one function that turns a submission into a Forms API call. Create lib/hubspot.ts.
The request shape comes from HubSpot's authenticated submission endpoint, which takes the portal ID and form GUID in the path and the token in the Bearer header:
// lib/hubspot.ts
export type HubSpotContext = {
hutk?: string
ipAddress?: string
pageUri?: string
pageName?: string
}
export type SubmissionResult = {
ok: boolean
status: number
redirectUri?: string
errorType?: string
}
const CONTACT_OBJECT_TYPE_ID = '0-1'
function toFields(values: Record<string, string>) {
return Object.entries(values)
.filter(([, value]) => value !== '' && value != null)
.map(([name, value]) => ({
objectTypeId: CONTACT_OBJECT_TYPE_ID,
name,
value: String(value),
}))
}
export async function submitToHubSpotForm(args: {
portalId: string
formGuid: string
values: Record<string, string>
context: HubSpotContext
consentText?: string
}): Promise<SubmissionResult> {
const token = process.env.HUBSPOT_PRIVATE_APP_TOKEN
if (!token) throw new Error('Missing HUBSPOT_PRIVATE_APP_TOKEN')
const body: Record<string, unknown> = {
submittedAt: String(Date.now()),
fields: toFields(args.values),
context: args.context,
}
if (args.consentText) {
body.legalConsentOptions = {
consent: { consentToProcess: true, text: args.consentText },
}
}
const response = await fetch(
`https://api.hsforms.com/submissions/v3/integration/secure/submit/` +
`${args.portalId}/${args.formGuid}`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
}
)
const payload = (await response.json().catch(() => ({}))) as {
redirectUri?: string
errors?: { errorType?: string }[]
}
return {
ok: response.ok,
status: response.status,
redirectUri: payload.redirectUri,
errorType: payload.errors?.[0]?.errorType,
}
}
These three details break submissions when they are wrong. Every entry in fields carries objectTypeId, which HubSpot's schema requires and older tutorials omit because it wasn't always enforced; 0-1 is the contact object.
submittedAt is a millisecond timestamp, and HubSpot rejects values more than a month old, so generate it at submission time rather than carrying it through a queue.
And page URL and page title belong in context as pageUri and pageName, never inside fields, because a form that never defined them as fields rejects the whole submission with FIELD_NOT_IN_FORM_DEFINITION.
I have the helper return a result rather than throw, because a HubSpot 400 is a data problem worth logging against the campaign, not an exception that should take the handler down with it. Reading the token inside the function body matters too: on Webflow Cloud I treat every process.env read as request-scoped rather than caching it at module level.
5. Build the campaign lead route handler
This is the one endpoint every campaign form on every client site posts to. It reads the routing data the browser sends, adds the context only the server can supply, and calls the helper.
Create app/api/campaign-lead/route.ts:
// app/api/campaign-lead/route.ts
export const runtime = 'edge'
import { NextResponse, type NextRequest } from 'next/server'
import { submitToHubSpotForm } from '@/lib/hubspot'
type LeadPayload = {
portalId: string
formGuid: string
campaignSlug: string
agencyClient: string
values: Record<string, string>
hutk?: string
pageUri?: string
pageName?: string
utm?: { source?: string; medium?: string }
consentText?: string
}
function clientIp(request: NextRequest): string | undefined {
// Cloudflare populates cf-connecting-ip and recommends it over
// x-forwarded-for. Webflow Cloud does not document request headers,
// so treat this as best effort and never require a value.
return (
request.headers.get('cf-connecting-ip') ??
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
undefined
)
}
export async function POST(request: NextRequest) {
let payload: LeadPayload
try {
payload = (await request.json()) as LeadPayload
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
if (!payload.portalId || !payload.formGuid) {
return NextResponse.json({ error: 'Missing routing data' }, { status: 400 })
}
if (!payload.values?.email) {
return NextResponse.json({ error: 'Email is required' }, { status: 422 })
}
const result = await submitToHubSpotForm({
portalId: payload.portalId,
formGuid: payload.formGuid,
consentText: payload.consentText,
values: {
...payload.values,
campaign_slug: payload.campaignSlug,
agency_client: payload.agencyClient,
utm_source_raw: payload.utm?.source ?? '',
utm_medium_raw: payload.utm?.medium ?? '',
},
context: {
hutk: payload.hutk,
ipAddress: clientIp(request),
pageUri: payload.pageUri,
pageName: payload.pageName,
},
})
if (!result.ok) {
console.error('HubSpot rejected submission', {
campaign: payload.campaignSlug,
status: result.status,
errorType: result.errorType,
})
return NextResponse.json(
{ error: result.errorType ?? 'Submission failed' },
{ status: result.status === 429 ? 429 : 502 }
)
}
return NextResponse.json({ ok: true, redirectUri: result.redirectUri })
}
The export const runtime = 'edge' line is not optional. Webflow Cloud runs your app on Cloudflare Workers, and Webflow's documentation says to add that directive to API routes. Local development works without it, but production fails, which is hard to diagnose because nothing in your dev output hints at it.
The clientIp helper earns its comment. HubSpot's schema documents context.ipAddress as the visitor's IP, and submissions that arrive without one carry a HubSpot warning about form analytics.
Cloudflare documents CF-Connecting-IP as the header carrying the client IP and recommends it over X-Forwarded-For because it holds a single address.
Webflow Cloud documents nothing about request headers, so I read the Cloudflare header first and let a missing value pass through as undefined. Sending your server's IP would be worse than sending none, because it can read as automated traffic.
6. Wire the Webflow form to the route handler
The client script is the smallest file in the build and holds the most edge cases. It intercepts the submit, reads what only exists in the browser, posts to the handler, and shows the form's own success or error state.
Add it as a Code Embed on the Collection page, or in Footer code if every campaign page shares it:
<script>
document.addEventListener('DOMContentLoaded', function () {
var BASE_PATH = '/campaigns' // must match NEXT_PUBLIC_BASE_PATH
function readCookie(name) {
var parts = ('; ' + document.cookie).split('; ' + name + '=')
return parts.length === 2 ? parts.pop().split(';').shift() : undefined
}
document.querySelectorAll('[data-hs-form-id]').forEach(function (wrapper) {
var form = wrapper.querySelector('form')
if (!form) return
var success = wrapper.querySelector('[data-campaign-state="success"]')
var failure = wrapper.querySelector('[data-campaign-state="error"]')
var button = form.querySelector('[type="submit"]')
form.addEventListener('submit', async function (event) {
event.preventDefault()
if (button) button.value = 'Sending...'
var values = {}
form.querySelectorAll('[data-hs-property]').forEach(function (input) {
values[input.dataset.hsProperty] =
input.type === 'checkbox' ? String(input.checked) : input.value
})
var params = new URLSearchParams(window.location.search)
try {
var response = await fetch(BASE_PATH + '/api/campaign-lead', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
portalId: wrapper.dataset.hsPortalId,
formGuid: wrapper.dataset.hsFormId,
campaignSlug: wrapper.dataset.campaignSlug,
agencyClient: wrapper.dataset.agencyClient,
values: values,
hutk: readCookie('hubspotutk'),
pageUri: window.location.href,
pageName: document.title,
utm: {
source: params.get('utm_source') || undefined,
medium: params.get('utm_medium') || undefined,
},
}),
})
if (!response.ok) throw new Error('Submission failed')
form.style.display = 'none'
if (success) success.style.display = 'block'
} catch (error) {
if (failure) failure.style.display = 'block'
if (button) button.value = 'Try again'
}
})
})
})
</script>
Four things are being done unnecessarily. readCookie('hubspotutk') pulls HubSpot's visitor identity token, a first-party cookie readable from JavaScript, and passing it as hutk is what lets HubSpot attach the visitor's earlier pageviews to the contact.
UTM parameters are read from the URL at submit time, because that is the only moment HubSpot captures them. The preventDefault() call stops Webflow's AJAX submission, so the data goes to HubSpot and isn't stored in Webflow.
And BASE_PATH is prepended by hand because Webflow Cloud handles routing for links and images but leaves client-side fetch calls to you.
For the state elements, add a custom attribute data-campaign-state with the value success or error to the Form Block's Success message and Error message elements.
Webflow generates its own state classes on published forms, but I target my own attribute instead because those generated class names aren't a documented contract, and a platform update can change them. Publish, submit, and the contact should appear in HubSpot within seconds with the campaign slug, client, and UTM values on the record.
7. Register the campaign in HubSpot and attach the form to it
The workflow works now, but the reporting doesn't. A HubSpot campaign is a real object with its own GUID, and until the form is associated with one, submissions do not roll up into campaign performance.
This script makes both calls, and I run it as part of launch rather than leaving it to someone to click through the UI:
// scripts/register-campaign.mjs
// node scripts/register-campaign.mjs "Q3 Demand Gen Webinar" q3-webinar <formId>
const TOKEN = process.env.HUBSPOT_PRIVATE_APP_TOKEN
const [name, utmValue, formId] = process.argv.slice(2)
const created = await fetch('https://api.hubapi.com/marketing/campaigns/2026-03', {
method: 'POST',
headers: {
Authorization: `Bearer ${TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
properties: {
hs_name: name,
hs_utm: utmValue,
hs_campaign_status: 'active',
},
}),
}).then((response) => response.json())
console.log('campaign', created.id)
const associated = await fetch(
`https://api.hubapi.com/marketing/campaigns/2026-03/${created.id}/assets/FORM/${formId}`,
{ method: 'PUT', headers: { Authorization: `Bearer ${TOKEN}` } }
)
console.log('form association', associated.status)
The first call creates the campaign and returns its GUID as id. hs_name must be unique and stay under 256 characters, hs_utm holds the campaign's UTM value under the same limit, and hs_campaign_status accepts planned, in_progress, active, paused, or completed.
Setting hs_utm to the string your campaign links use connects tracked traffic to the campaign, and I confirm the value lands in the campaign record before any media spend starts.
The second call is the association, and its shape is worth memorizing: a PUT to the campaign's assets path with the asset type and asset ID in the URL and no body. FORM is the asset type for forms, and it unlocks the conversion rate, submissions, and views metrics.
One constraint to design around is that adding an asset that already belongs to another campaign removes it from that campaign, so a form used by two campaigns needs to become two forms. Store the returned GUID on the Collection item.
What causes Webflow Cloud to HubSpot campaign workflows to break?
The most costly failures are the ones where the submission succeeds. A form that returns its success state while the data lands wrong, twice, or without attribution will not appear in any error log, and the marketing team finds it in a report rather than in a bug.
That makes the order of investigation matter more than usual, so start with the one that looks like a HubSpot bug but isn't.
Every submission appears twice in HubSpot
This is the most reported failure in the whole integration, and the cause is two capture mechanisms on one form. If HubSpot's non-HubSpot form capture is on and you also submit through the app or the API, the tracking script scrapes the DOM form while your integration posts the same data, so the contact shows two submissions under two differently named forms.
A Webflow-specific variant is even nastier. Webflow prefixes generated form IDs with wf-form-, so one form can present two distinct selectors to HubSpot's script, one matching the form name and one the generated ID, and HubSpot registers them as two separate non-HubSpot forms.
In both cases, the fix is to turn off non-HubSpot capture for the domain. Re-mapping a form in the HubSpot App can also generate an extra form that then receives every submission alongside the original, so audit the forms list afterward.
HubSpot rejects submissions with FORM_HAS_RECAPTCHA_ENABLED
Turn on CAPTCHA for a HubSpot form and API submissions to it fail. HubSpot states it plainly: "If CAPTCHA has been turned on in the form, form submissions from the Submit data for a form API or other form integrations will not be accepted."
You get a 400 whose errorType is FORM_HAS_RECAPTCHA_ENABLED, and the human-readable message is undocumented, so log the error type instead.
That is a real bind, because people turn CAPTCHA on for a reason, and spam on campaign forms isn't hypothetical. Move spam protection upstream to the layer that owns the submission: add reCAPTCHA on the Webflow side and validate the token in your route handler before calling HubSpot, or add a honeypot the handler checks and silently discards.
The Webflow half is covered in the guide to add reCAPTCHA to forms. Either way, the check belongs in the handler, because a client-side-only honeypot still lets data through.
HubSpot warns that the cookie needed to link submissions is not being sent
This warning appears on the submission itself and means context.hutk arrived empty. Three causes account for nearly all of it: the tracking code isn't live because the site wasn't published, the visitor declined cookies, or an extension blocked the script, or your script read the cookie before it was set.
The key point is that you can't fix this later. HubSpot does not backfill pageview association for a submission that arrived without the token, and the same goes for the IP address. So build for its absence: the contact is still created and matched on email; you only lose the earlier session history.
I log a counter when hutk is missing, because the cookie is first-party and set on every pageview, so a rate that jumps from near zero to most submissions can only mean the snippet stopped loading on that client's site.
UTM values are empty on contacts from paid campaigns
The classic version has nothing to do with your code. HubSpot captures UTM parameters that are in the URL at the moment of submission, and the standard embed renders in an iframe that parent-page scripts cannot reach.
A flow that sends traffic to a tagged landing page and then clicks through to a clean-URL form page loses attribution entirely, and no hidden field configuration on the embedded form fixes it.
Reading the parameters in the browser and sending them to your own handler, as step 6 does, solves the iframe half. It does not solve the multi-page half. If the form lives on a different page from the one the ad points at, carry the parameters forward by appending them to the internal link or writing them to sessionStorage on first landing and reading them back at submit time.
Also confirm you are not relying on hidden fields with HubSpot's non-HubSpot capture, which doesn't collect them at all.
The route handler works locally but returns a 404 in production
This is the mount path, and it is the most common Webflow Cloud mistake I see. Your app is served at a subpath, so an app mounted at /campaigns exposes its route at /campaigns/api/campaign-lead.
A fetch('/api/campaign-lead') resolves against the site root, where nothing is listening, and Webflow returns the site's 404 page. Locally, there is no mount path, so the same call works.
Webflow Cloud handles the prefix for framework navigation and images, but client-side fetch calls are explicitly your responsibility. Prefix them from NEXT_PUBLIC_BASE_PATH rather than a hardcoded string.
Do not set basePath or assetPrefix in your Next.js config to compensate, because Webflow Cloud sets both at build time from the mount path and overwrites what you commit. Check one related symptom first: a 401 rather than a 404 usually means the token was added after the last deploy.
Test submissions keep attaching to the wrong contact
This only shows up during QA, which is when it does the most damage to your confidence in the build. Submitting several test leads from one browser makes HubSpot match every one to the HubSpotUTK cookie already there, so instead of five contacts you get one contact overwritten five times.
HubSpot's documented behavior is that email is the primary identifier. Still, where no contact exists at that email, the cookie is used to recognize and update an existing contact, and property values including the email itself can be overwritten.
An early nameless test submission makes it worse because a contact with no name is still a valid contact, and later submissions keep matching to it. Test in a fresh incognito window per submission, use distinct addresses, and delete the test contacts before handover.
If a client needs a new contact for every new email from one browser, HubSpot's Always create new contact for new email option, set on the individual form, changes that behavior.
Scale your Webflow Cloud to HubSpot campaign workflow across client accounts
With clean campaign context on every contact, variant testing becomes measurable rather than anecdotal, because each variant's submissions carry the same properties and land in the same campaign. The methodology in our guide to A/B testing pairs directly with the campaign slug you are already writing.
Handover is the other thing to plan for, because a workflow only you can operate is a bottleneck wearing the costume of a system. Document which Collection fields the marketing team owns, which values come from HubSpot, and what the active switch does, following the steps in the guide to hand off client sites.
When you are ready to extend the integration itself, the HubSpot integration page covers the app-based connection methods and form styling options that a client's in-house team reaches for first.
Frequently asked questions
Do submissions still appear in Webflow's form submissions?
No. Calling preventDefault() stops Webflow's own handler, so nothing is stored in Webflow, and no Webflow notification is sent. If you need a copy on the Webflow side, write it back through the Data API or keep a separate form using the native app.
Can I run this without a Marketing Hub Professional subscription?
Yes, for the submission path. The Forms API and the contacts and properties APIs are available to standard private app scopes. Only the campaign creation and asset association in step 7 need marketing.campaigns.write, so without it you keep lead capture and lose campaign roll-up reporting.
What happens if the visitor blocks the HubSpot cookie?
The submission still succeeds, and the contact is still created or matched on email. You lose the visitor's earlier pageview history because that association depends on the HubSpotUTK token. HubSpot cannot backfill it afterward, so treat a missing cookie as expected rather than a failure.
How many submissions per second can the Forms API handle?
HubSpot's published figures for the authenticated endpoint are 100 requests per 10 seconds on Free and Starter and 150 on Professional and Enterprise. Those numbers come from a 2021 changelog and are not restated on the current limits page, so read the rate limit response headers for live values.
Can one route handler serve campaigns across different HubSpot portals?
Yes, and that is why the portal ID comes from the page rather than the environment. One private app token cannot span portals, though, so a multi-portal setup needs a token per portal, resolved server-side from the incoming portal ID.
Does this approach work on a Webflow site without Webflow Cloud?
Not safely. The private app token has to stay server-side, and without a route handler, you would submit from the browser with the token exposed in the page source. If Webflow Cloud isn't available, use the native HubSpot App or an automation platform instead.




