ClickFunnels SDK: External Pages and Funnels
Published: Updated:
Overview
Section titled “Overview”The ClickFunnels SDK lets a page you host anywhere on the web behave like a native ClickFunnels funnel step. Visitors are tracked, forms submit into ClickFunnels — creating contacts, firing opt-in events, triggering your automations — and contact and visit identity carries across steps, even when those steps live on different domains.
You wire this up with three tags and one attribute. That is the entire public surface; everything else is internal to the SDK and handled for you.
What you can build
Section titled “What you can build”The SDK is not limited to bolting one outside page onto an otherwise ClickFunnels-hosted funnel. A page registered this way is called an external page, and because identity carries across steps and domains, external pages compose into three shapes:
-
An entire funnel hosted externally — every step (opt-in, order form, one-time offer, thank-you) registered as its own external page on your own domain, with ClickFunnels running the funnel logic, tracking, contacts, payments, and automations behind it.
-
Funnel functionality added to a site you already run. You don’t have to build something new. Add the SDK to your existing marketing site, app, or single-page app and turn forms you already have into ClickFunnels opt-in and checkout steps.
-
A single external page as one step of an otherwise ClickFunnels-hosted funnel — for example your own custom-built order form in the middle of a standard funnel.
Where you host is up to you: your own domain, a static host, a Netlify or Vercel
site, or http://localhost while you develop.
The two things external pages capture today are opt-ins and checkout.
What you need before you start
Section titled “What you need before you start”-
A funnel for the page (or pages) to live in. External pages are funnel steps. You can register a page before it has a step — see Registering a page before it has a step — but tracking is all you get until it joins a funnel.
-
A page whose HTML you control. You need to be able to add two tags and one attribute. A page you can’t edit the markup of can’t be an external page.
-
A payment gateway on the funnel — only if you’re adding checkout. Payments AI (including Stripe connected through Payments AI) and an existing direct Stripe connection are both supported, and the page markup is identical for either.
Building with an AI assistant
Section titled “Building with an AI assistant”An AI assistant driving the work isn’t a shortcut around this feature — it’s how the feature is designed to be used.
The SDK’s documentation is published first as machine-readable skill
documents written for AI coding agents; the
reference material at the end of this article
links them, and this article is the human-readable derivation of them. The contract
your page has to satisfy — the meta tag, sdk.js, and the data-cf-element value
each field needs — is authored to be read by an agent and applied to your markup.
The ClickFunnels MCP Server is the other half of that design. It connects an assistant like Claude or ChatGPT to your workspace and serves it those same external-page and checkout guides, so the assistant works from the real contract rather than guessing at markup or scraping documentation off the web — and it can act on your workspace, not only read about it.
With the connector in place, you can ask your assistant to:
-
Register the external pages. Create a page from the URL you’ll host it at, attach it to a funnel as a step, position it after an existing step, and report back the token to paste into your markup — repeated for each step of a fully external funnel.
-
Write the page markup with you. An opt-in form, a checkout form, or a one-time offer, built against the real element contract instead of guesswork.
-
Set up what checkout needs. Create products and prices, then attach them to the step as main products or order bumps.
-
Read back the funnel’s structure. The full step tree — steps, order, types, and pages — so you can confirm where each external page landed and what the visitor hits next.
-
Report on how the pages are doing. Funnel and page stats, once traffic is flowing.
If your assistant can build and host web pages too — natively, or through another connected integration that deploys them — it can go further and propose building the pages itself and registering them as external pages. It will tell you which hosting model it intends to use, ClickFunnels-hosted or externally hosted, and confirm with you before creating anything.
Everything from here on documents that same contract for a person to read. Follow it to build a page by hand, and to review, debug, or extend what an assistant built for you.
How it works
Section titled “How it works”Three things make a page an external page:
-
Registration. You tell ClickFunnels the page’s public URL. ClickFunnels mints a page token for it.
-
Two tags on the page. The token in a meta tag, and the SDK script.
-
One attribute on your fields.
data-cf-elementtells the SDK what each field is.
The SDK only works on a registered page — registration is what mints the token. The token, not the URL, is how the SDK identifies the page. The URL still matters: the SDK validates the URL the browser is actually on against the URL you registered, so a token pasted onto the wrong page is rejected.
Step 1: Register the page in ClickFunnels
Section titled “Step 1: Register the page in ClickFunnels”Open the funnel, click the + on the connector where the step belongs, and in the Add Step panel choose External page under Funnel — described in the panel as “Use the ClickFunnels SDK to include a page hosted outside ClickFunnels.”
Fill in the step settings:
| Field | What it is |
|---|---|
| Name | The step’s name in the funnel builder. Required. |
| External URL | The page’s public URL, with a protocol selector for http / https. Required. |
| Description | Optional note for your own reference. |
Save, and the step settings show a Page token section with the page’s token and a Copy button, plus the script tag for your account’s SDK host. Those are the exact two snippets to paste into your page.
Repeat this for every step you want to host yourself. One external step in an otherwise ClickFunnels-hosted funnel is the simplest case, but nothing stops you registering every step of the funnel this way — see Building an entire funnel externally.
The External URL must match where the page actually lives, origin and path.
/oto and /oto.html are different pages. If they diverge, the SDK’s init call
fails with a 403 url_mismatch. When a page later moves, update External URL
on the step — the token doesn’t change, so you don’t re-paste the meta tag.
Registering a page before it has a step
Section titled “Registering a page before it has a step”A page can also be registered standalone, with no funnel step yet. The token
is minted and visit tracking works, but form submission and checkout return a
page_not_in_funnel error until the page becomes a funnel step. This is how you
prepare a page that will later be dropped into a split-test variant or a
conditional-split branch — those slots take a page id, never a URL.
Registering over the API
Section titled “Registering over the API”There’s also an API for registration, currently in closed alpha — request access at the Developer Hub. It uses the ClickFunnels V2 API with a Bearer token:
POST /api/v2/workspaces/:workspace_id/pages/externalAuthorization: Bearer <access_token>Content-Type: application/json
{ "page": { "name": "Upsell", "external_url": "https://acme.example.com/upsell", "funnel": { "funnel_id": "<funnel_public_id>" } }}-
external_urlis the only required field. -
The
funnelblock is optional:funnel.funnel_idappends or inserts into that funnel,funnel.show_page_step_idswaps the page onto an existing step. Omit the block entirely to register standalone. -
Position it with
sort_order(a 0-based index) orfunnel.after_show_page_step_idto insert right after a specific step. Omit both to append at the end. Positioning parameters withoutfunnel.funnel_idare a 422.
The response carries an sdk block with the token and URL the page needs:
{ "sdk": { "token": "cfp_aB3xY7zQ9pK2mNvT8r5JcDef", "external_url": "https://acme.example.com/upsell" } }An external page has no ClickFunnels-hosted URL — its current_path is null,
because its address is the external_url you host. Afterwards,
PATCH /api/v2/pages/:id updates the name, description, external URL, or
position, and DELETE /api/v2/pages/:id detaches and removes it.
Step 2: Add the token and the SDK script
Section titled “Step 2: Add the token and the SDK script”Paste the token ClickFunnels issued into a meta tag in <head>:
<head> <meta name="cf-page-token" content="cfp_aB3xY7zQ9pK2mNvT8r5JcDef"></head>The token’s format is cfp_ followed by 24 alphanumeric characters. One token
per page — never paste the same token onto two different pages. And the meta
tag is the only place the SDK reads it from: a URL parameter, a JavaScript
global, or an attribute on the script tag does nothing.
Then load the SDK. Anywhere on the page works, because it discovers and enhances forms continuously, including forms added later:
<script src="https://sdk.myclickfunnels.com/sdk.js" defer></script>Use the exact script tag the funnel builder shows you — it points at your
account’s SDK host. defer lets the page finish parsing before the SDK runs,
which avoids race conditions no matter where the tag sits.
Single-page apps and existing sites
Section titled “Single-page apps and existing sites”This is what makes it practical to add funnel functionality to a site you already
run. In a single-page app, load sdk.js once for the whole app — the SDK
keeps watching for forms, and for changes to the token, across client-side route
changes, so you don’t re-add it per route.
When your app swaps to a different logical page, update the meta tag’s content
attribute to that page’s token. The SDK observes the meta tag and re-initializes
against the new token.
Step 3: Mark up your fields with data-cf-element
Section titled “Step 3: Mark up your fields with data-cf-element”A single attribute identifies every SDK-aware element, and its value names what the element is:
<form> <label for="signup-email">Email <span aria-hidden="true">*</span></label> <input id="signup-email" name="email" data-cf-element="email" type="email" autocomplete="email" required> <label for="signup-first-name">First name</label> <input id="signup-first-name" name="first_name" data-cf-element="first-name" autocomplete="given-name"> <label for="signup-last-name">Last name</label> <input id="signup-last-name" name="last_name" data-cf-element="last-name" autocomplete="family-name"> <button type="submit">Continue</button></form>Values are flat, kebab-case identifiers — no flow prefixes, because a field is a
field regardless of which funnel it’s in. Keep your own name and id
attributes for labels, styling, and accessibility: the SDK reads a field’s
identity from data-cf-element, not from name or id, and builds the
submission payload itself.
There is no form-level marker. Mark the fields, not the form — the SDK finds the form by walking up from the marked elements inside it.
The element registry
Section titled “The element registry”| Value | Tag(s) | Purpose |
|---|---|---|
email |
<input> |
Email capture |
first-name |
<input> |
First name |
last-name |
<input> |
Last name |
name |
<input> |
Single-field name (alternative to first/last) |
phone |
<input> |
Phone number |
custom-attribute:<key> |
<input>, <select>, <textarea> |
Any other contact attribute — <key> is yours |
Anything beyond the fixed fields is a custom contact attribute: use a colon followed by your key. The key passes through verbatim, and the attribute is created in your workspace on the fly the first time it’s seen, so you don’t have to pre-create fields. This is the only element value that contains a colon.
<select data-cf-element="custom-attribute:business_type">...</select><input data-cf-element="custom-attribute:monthly_revenue">For an opt-in you don’t need a special submit element — a plain
<button type="submit"> works, because the SDK takes over the form’s submit.
Selling from the page adds more elements; see Adding checkout.
How submission behaves
Section titled “How submission behaves”Any field that submits must be inside a <form> — the form is the submission
scope.
-
Per-form scoping. Each
<form>containing adata-cf-elementis an independent submission. Two such forms produce two separate submissions; the SDK never merges values across forms. -
The SDK owns submission. On submit it prevents the default, builds the payload itself, POSTs to its own endpoint, and follows the redirect to the next funnel step. You don’t set an
actionormethod. That POST is a real form navigation, not afetch— which is exactly what lets it follow the redirect — so a page served with a Content Security Policy must name the SDK host inform-action. -
Don’t mix non-SDK fields into an SDK form. A form containing any
data-cf-elementis SDK-owned; put fields destined for a different endpoint in a separate form. You don’t need hidden UTM fields — visit tracking already captures UTMs from the landing-page URL.
The SDK logs a console warning and ignores an element on the wrong tag, a
submission field placed outside any <form>, and a non-SDK <input>,
<select>, or <textarea> sitting inside an SDK form. Open the console — it
tells you exactly what it skipped and why.
A complete opt-in page
Section titled “A complete opt-in page”<!doctype html><html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Join the list</title>
<!-- 1. Page token from ClickFunnels --> <meta name="cf-page-token" content="cfp_aB3xY7zQ9pK2mNvT8r5JcDef"> </head> <body> <form> <!-- 3. Fields the SDK operates on. Keep your own id/name for labels. --> <label for="first">First name <span aria-hidden="true">*</span></label> <input id="first" name="first_name" type="text" data-cf-element="first-name" autocomplete="given-name" required />
<label for="email">Email <span aria-hidden="true">*</span></label> <input id="email" name="email" type="email" data-cf-element="email" autocomplete="email" required />
<label for="biz">Business type</label> <select id="biz" name="business_type" data-cf-element="custom-attribute:business_type"> <option value="agency">Agency</option> <option value="ecom">E-commerce</option> </select>
<button type="submit">Continue</button> </form>
<!-- 2. The SDK --> <script src="https://sdk.myclickfunnels.com/sdk.js" defer></script> </body></html>Building an entire funnel externally
Section titled “Building an entire funnel externally”Nothing about the SDK restricts you to one external step. Register each step as its own external page and the whole funnel can live on your infrastructure, with ClickFunnels running the funnel logic behind it. Contact and visit identity is carried between steps by the SDK, so it survives the hop from one page to the next even across different domains.
A fully external funnel behaves like any other funnel: steps can be reordered, split-tested, and branched, and the funnel’s stats, contacts, orders, and automations all work normally.
Splits at the funnel entry
Section titled “Splits at the funnel entry”A split test or conditional split decides which page a visitor sees. When that decision is the funnel’s first step there is no page to land on yet — the choice has to be made before the visitor reaches any specific page. ClickFunnels makes that decision at the funnel’s own URL (the Start of Funnel URL in the funnel builder) and then 302-redirects the visitor to the chosen step’s external URL.
So when the entry step is a split, the entry point you advertise must be the funnel URL, not a variant’s URL. A visitor who loads a variant page directly has bypassed the decision and just sees that variant — loading a page only runs that page’s own visit tracking; the split itself is evaluated at the funnel URL. This applies to both kinds of split: a split test allocates a variant by weight, and a conditional split evaluates the stored filter and routes to the matched or unmatched branch.
The choice is per-visitor sticky, carried by the visitor’s cookie. A refresh or a return visit sends the same person to the same variant, so a split test won’t flip between A and B on reload, and a conditional split keeps routing a contact to the branch they already qualified for.
Branding the entry on your own domain
Section titled “Branding the entry on your own domain”You usually want visitors to enter on your domain. But a hostname resolves to only one place — it’s either your external host or ClickFunnels, never both — and the funnel URL must be served by ClickFunnels. Bridge the two with a redirect: keep the funnel URL on a ClickFunnels-served host, and point your domain’s root at it.
-
Register each entry variant at a non-root path. The bare root
/is going to be the redirect, so it can’t also be a page — for example variant A athttps://www.example.com/a.htmland variant B athttps://www.example.com/b.html. Wire those as the split’s two branches. -
Put the funnel URL on a ClickFunnels host. Use the funnel’s public URL as-is, or add your own subdomain to ClickFunnels and assign it with Set Domain on the Start-of-Funnel step.
-
Redirect your root to the funnel URL — only the exact root path. With Netlify, in
netlify.toml:
[[redirects]] from = "/" to = "https://hostedonclickfunnels.example.com/your-funnel" status = 302 force = trueRedirect only / — never the variant paths, or you create a loop (root →
funnel → variant → root → …). Visitors then enter at https://www.example.com/
and reach a variant in two redirects, with no loop.
Moving an external funnel to a new domain
Section titled “Moving an external funnel to a new domain”Because the token identifies the page rather than the URL, you can move external pages between origins without re-pasting anything: stand the pages up at the same paths on the new origin, then update each page’s external URL in ClickFunnels. Every page keeps its token, its funnel branch, its visit tracking, and its form submissions. There’s a dedicated Migrate an External Page Domain guide covering the safe order of operations, including funnels with split tests and nested branches.
Adding checkout
Section titled “Adding checkout”Checkout turns a registered external page into an order form. The SDK mounts the gateway’s card UI, prices the cart on the server (tax, discounts, shipping), charges the buyer including any 3-D Secure challenge, and redirects to the funnel’s next step.
Everything above still applies — the page must be a registered external page step
carrying its token and sdk.js. Checkout adds product configuration in
ClickFunnels and a few more elements on the page.
Configure the products in ClickFunnels
Section titled “Configure the products in ClickFunnels”-
Attach the products you’re selling to the step, in the step’s products area.
-
Set each variant’s
product_typeto match the real goods.physicalmakes checkout collect a shipping address and shipping options;digitalskips both. An unset product type behaves like digital, so physical goods sold with it unset silently produce orders with no shipping address. -
Order bumps are products dragged into the step’s Order bump section. Only products marked as bumps are served to the page as bumps — a regularly attached product will never appear as one.
-
One-time offers attach to the next step, which is itself an SDK page — another external page, or a ClickFunnels-hosted Custom HTML page.
-
The funnel needs a configured payment gateway. Without one, the SDK logs
products(): no checkout configuration for this pageand no payment UI mounts.
The checkout form
Section titled “The checkout form”Checkout uses the same single-attribute convention, with everything inside one
<form>:
<form aria-labelledby="checkout-heading"> <h2 id="checkout-heading">Checkout</h2> <div id="checkout-errors" role="alert" aria-live="assertive" aria-atomic="true" hidden></div>
<label for="checkout-email">Email <span aria-hidden="true">*</span></label> <input id="checkout-email" name="email" data-cf-element="email" type="email" autocomplete="email" required> <label for="checkout-first-name">First name</label> <input id="checkout-first-name" name="first_name" data-cf-element="first-name" autocomplete="given-name"> <label for="checkout-last-name">Last name</label> <input id="checkout-last-name" name="last_name" data-cf-element="last-name" autocomplete="family-name">
<div data-cf-element="shipping"></div> <div data-cf-element="billing"></div> <div data-cf-element="payment-method"></div>
<button data-cf-element="pay" type="submit">Pay now</button></form>| Value | Tag | Purpose |
|---|---|---|
payment-method |
<div> (empty) |
Mount point — the SDK renders the gateway’s card UI |
pay |
<button> or <input type="submit"> |
Submits the purchase; disabled while a charge runs |
shipping |
<div> (empty) |
Shipping-address mount point (checkout form or OTO page) |
billing |
<div> (empty) |
Billing-address mount point |
billing-same-as-shipping |
<input type="checkbox"> |
Optional; checked mirrors shipping into billing |
oto-accept |
<button> |
OTO pages only — one-click buy; value="variantId|priceId" |
oto-decline |
<button> |
OTO pages only — advance without buying |
The mount-point <div>s stay empty in your HTML — write only the tag and the
attribute, and let the SDK own their contents. Don’t read, replace, or attach
behavior to what it mounts there.
Always include the shipping element, even when today’s product is digital.
The SDK shows the address forms only while the cart and gateway require them, so
the element costs nothing while hidden — and a product later switched to physical
just works, with no page change. Omit it and that same change breaks checkout with
a shipping-address error and nowhere to enter one. Adding data-cf-visible keeps
the address form visible at all times regardless of product type, for merchants
who want to collect a shipping address on a digital product that doesn’t require
one.
Contact identity fields prefill automatically for a visitor moving through the funnel in the same session — what they typed on an earlier opt-in step fills any still-empty inputs here. On success there is no success event: the browser follows the server’s redirect to the next step.
Put the product in the cart
Section titled “Put the product in the cart”The markup gives the SDK somewhere to mount and submit payment, but it doesn’t guess which attached product or price to sell. For a single-product order form, selecting that line is the only custom JavaScript required:
<script> document.addEventListener('DOMContentLoaded', () => { CfSdk.products().then(({ products }) => { const variant = products[0]?.variants.find(({ prices }) => prices.length > 0) const price = variant?.prices[0] if (price) CfSdk.checkout.setQuantity(variant.id, price.id, 1) }) })</script>Stop there unless the page needs a product picker, quantities, order bumps, custom totals, coupons, or shipping-option selection.
The CfSdk JavaScript API
Section titled “The CfSdk JavaScript API”window.CfSdk is the supported host-page API — not access to SDK internals. It’s
callable as soon as the script tag has executed (calls queue until the SDK
finishes booting); with defer, run your code on DOMContentLoaded or later.
| Call | Purpose |
|---|---|
CfSdk.products() |
The step’s catalog — main products and order bumps |
CfSdk.checkout.setQuantity(variantId, priceId, qty) |
Set a cart line (0 removes it) |
CfSdk.checkout.updateState({...}) |
Partial update: coupon_codes, shipping_address, billing_address |
CfSdk.checkout.setShipping(optionId) |
Pick a shipping option from the current state |
CfSdk.checkout.currentState() |
Read the current state |
CfSdk.checkout.onStateChange(fn) |
Fires on every committed state — your single render path |
CfSdk.checkout.onPending(fn) |
A mutation or charge is in flight |
CfSdk.checkout.onError(fn) |
A state carrying errors was committed |
CfSdk.checkout.onOtoOutcome(fn) |
OTO pages only |
Two things worth getting right:
-
Render from
onStateChange, not from each mutator’s resolved value. It also fires for SDK-internal repricing — an address change re-quoting shipping, for example — so it’s the one place your totals can’t go stale. -
Keep the two money formats straight. Checkout state amounts are integer minor units (cents; zero-decimal currencies like JPY are ×1) and are the authoritative numbers to render. Catalog prices from
products()carryamountas a decimal string, which is fine for displaying an offer, but totals come from state.
An empty cart reports a no_products validation error — that’s the normal
starting state, not something to alarm the buyer with.
An order bump isn’t a special API: it’s a catalog product served in
bumpProducts, and selecting it is one more setQuantity line, priced by the
server. Keep the bump’s checkbox — and any other control of your own — outside
the SDK form; a non-SDK field inside it draws a console warning and is dropped
server-side.
If the cart needs shipping, the SDK fills the shipping container with the
gateway’s address UI, and any shipping options the server quotes appear in the
state. The cheapest is selected automatically, so a rate picker is optional.
One-time offers
Section titled “One-time offers”An OTO page is the funnel step after the checkout: the buyer’s card was saved
on the previous purchase, so accepting is one click — no email, no card entry, and
no <form>. Make it its own SDK page — an external page, or a
ClickFunnels-hosted Custom HTML page;
the one-click grant works the same on either — attach the offered product to its
step, and give it the two buttons plus a shipping element.
-
The offered line rides the accept button’s
valueattribute asvariantId|priceId. Clicking it charges the saved payment method and follows the redirect to the next step;oto-declineadvances without charging. As withpay, success is a redirect, not an event. -
OTO elements are intentionally formless — they’re the exception to the “submission elements need a
<form>” rule. -
The one-click grant only exists when the visitor arrives via the previous step’s successful checkout redirect. Loading the OTO page directly fires
onOtoOutcomewith a status ofunavailable— always handle it by redirecting to your order form, or direct visitors are stranded on a dead offer. -
Every OTO page needs
oto-decline, or a fallback checkout form (payment-methodpluspay). The SDK warns in the console otherwise, because a buyer whose saved-card charge fails would have no way forward. -
Always include the
shippingelement on an OTO page too. A physical offered product must ship somewhere, and the buyer may have no address on file — a digital course followed by a t-shirt upsell is the classic case. The SDK prices the offer on load and shows the address form only when the offer needs an address the initial checkout didn’t collect.
Testing checkout
Section titled “Testing checkout”While the funnel is not live, payments run in test mode: use your gateway’s test cards. In test mode the SDK also shows a debug overlay badge on the page — open it, because it shows the token, init status, the page URL versus the expected URL, and the failure reason, which answers most “why isn’t this working” questions directly.
Test the funnel the way a buyer would: start on the order form, pay with a test
card, land on the OTO, accept or decline, and reach the thank-you page. Don’t
deep-link into the OTO page and expect one-click to work. While the funnel is in
test mode, loading an OTO page directly does work — the offer prices and renders
normally and accept/decline are inert — so keep your unavailable redirect
handling unconditional, because it applies once the funnel is live.
Content Security Policy
Section titled “Content Security Policy”Most hosts send no CSP at all, and then there’s nothing to configure. But if your
page is served with a Content-Security-Policy — a response header or a
<meta http-equiv="Content-Security-Policy"> — it has to allow the SDK host in
three directives:
| Directive | Why the SDK needs it |
|---|---|
script-src |
sdk.js is a small loader; it injects the real SDK bundle from the same host |
connect-src |
Init, the submit nonce, and visit/event tracking are fetch/XHR calls |
form-action |
The opt-in submit is a form navigation, not a fetch |
Every request the SDK makes goes to that one host, so a single origin in each directive covers all of it:
Content-Security-Policy: script-src 'self' https://sdk.myclickfunnels.com; connect-src 'self' https://sdk.myclickfunnels.com; form-action 'self' https://sdk.myclickfunnels.com;Checkout pages need more. The card fields are payment-provider iframes, so the
provider’s own hosts — https://js.stripe.com or https://framepay.payments.ai
depending on the gateway — need script-src and frame-src, and FramePay also
loads a stylesheet, so it needs style-src. Each provider documents its full CSP
requirements; start there if a field renders blank.
Local and self-hosted testing
Section titled “Local and self-hosted testing”-
http://localhost(recommended). Tracking and submits work, so you can exercise the full flow end to end, including the redirect to the next step. -
file://(opened straight off disk). The SDK loads and visit tracking fires, but submits don’t work — browsers block the 302 redirect afile://POST has to follow. Use it only to confirm the script loads and tracks.
A file:// page also can’t be registered: external_url accepts only http and
https. It’s purely a local SDK-loading convenience.
Getting started checklist
Section titled “Getting started checklist”-
Confirm Add Step shows External page in the funnel builder.
-
Decide the shape: an entire external funnel, funnel functionality added to a site you already run, or one external step.
-
Create or pick the funnel, then Add Step → External page for each step you’ll host yourself, setting Name and External URL.
-
Copy each step’s Page token from its step settings.
-
Paste each token into
<meta name="cf-page-token">in that page’s<head>. -
Add the
sdk.jsscript tag shown in the step settings — once per page, or once per app for a single-page app. -
Add
data-cf-elementto your form fields. -
Load the page and open the browser console — confirm the SDK initializes and reports the form it found.
-
Submit the form and confirm the contact appears in ClickFunnels and the visitor lands on the next step.
-
Selling from the page? Attach products to the step, add the checkout elements, select the cart line, and place a test-mode order.
-
Serving a CSP? Add the SDK host to
script-src,connect-src, andform-action— plus your payment provider’s hosts on checkout pages.
Working with an AI assistant? Connect the ClickFunnels MCP Server first and it can carry out steps 3–4 for you, help you write the markup in steps 5–7, and attach the products in step 10. Loading the page and placing the test order are still yours to do in the browser.
Reference material and feedback
Section titled “Reference material and feedback”The SDK ships machine-readable “skill” documents intended for AI coding agents. This article is derived from them, and they remain the authoritative, always-current source for the markup and API details:
-
Developer Hub — API reference and guides
-
ClickFunnels MCP Server — connect an AI assistant to your workspace and let it drive the steps above
-
Custom HTML Pages — the same page contract, with ClickFunnels hosting the HTML for you
Troubleshooting
Section titled “Troubleshooting”My form submits, but the page just sits there. What's wrong?
This is almost always a Content Security Policy that doesn’t name the SDK host in its form-action directive.
The SDK’s submit is a real form navigation rather than a fetch — that is what lets it follow the redirect to the next funnel step. If your page is served with a CSP that omits the SDK host, the browser blocks that navigation and nothing visible happens. The console names the directive: “Sending form data to … violates the following Content Security Policy directive: form-action”.
It’s a confusing failure because everything else looks healthy: the SDK logs a successful init, reports the forms it found, sets its cookies, and rewrites each form’s action. Only the final navigation is blocked.
The directive is often injected by your hosting platform rather than written by you, so check what is actually served: curl -sSI https://your-page.example.com | grep -i content-security-policy
One related detail while you debug: the SDK’s double-submit guard latches on the first submit, so a second click after a blocked one is ignored on purpose. Reload the page between attempts.
Remember the SDK needs all three directives — script-src, connect-src, and form-action — not just form-action.
The SDK isn't doing anything at all. Where do I start?
Work through the usual causes in order.
The most common is no data-cf-element anywhere. The SDK discovers fields only by that attribute; a field’s name or id alone does nothing.
The next is a form-level marker. There is no form opt-in attribute — mark the fields, not the form.
Then check that the token is in the meta tag and nowhere else, that sdk.js points at your account’s SDK host, and that the URL in the browser matches the URL you registered.
Open the browser console: the SDK names exactly what it skipped and why. If the funnel is in test mode, open the debug overlay badge too — it shows the token, init status, the page URL versus the expected URL, and the failure reason.
Init fails with a 403 url_mismatch. What does that mean?
The URL the browser is on doesn’t exactly match the URL registered for that page — path included. A trailing file extension is significant: /oto and /oto.html are different pages.
Fix either the registered external URL or the link that got the visitor there.
Note that init responses, including errors, are cached in the browser for about two minutes. Hard-refresh after changing a registration, or a fix can look like it didn’t take.
Can I test by opening the page straight off disk, from a file:// URL?
Only partially. The SDK loads and visit tracking fires, but submits do not work — browsers block the 302 redirect that a file:// POST has to follow.
Use http://localhost instead. Tracking and submits both work there, so you can exercise the whole flow end to end.
A file:// page also can’t be registered as an external page: registration accepts only http and https URLs.
Can I duplicate or share a funnel that contains an external page?
No. While a funnel contains an external page, cloning it, sharing it, and including it in a resource package are all blocked.
Can I use the same external page in more than one funnel step?
No — an external page can live in exactly one funnel step, and each page needs its own token.
External URLs carry no per-step disambiguation, so reusing one across two steps would make it ambiguous which step a submit should advance.
I registered a page but submissions return page_not_in_funnel. Why?
The page was registered standalone — the token was minted and visit tracking works, but the page isn’t a funnel step yet, so form submission and checkout have no next step to advance to.
Attach it to a funnel, or place it into a split-test variant or conditional-split branch. Those slots take a page id rather than a URL, which is exactly what standalone registration is for.
No payment or address fields are appearing on my checkout page. What should I check?
If nothing mounts at all and the console says checkout isn’t configured for the page, then init failed, no products are attached to this page’s step, the funnel has no payment gateway, or checkout isn’t enabled for the workspace.
If address fields specifically don’t appear, that’s expected for a digital-only cart — they mount only when the cart requires shipping. If you are selling physical goods, check the variant’s product_type: it must be physical, since an unset type behaves like digital.
If a card field renders blank on a page with a Content Security Policy, add your payment provider’s hosts — js.stripe.com or framepay.payments.ai — to script-src and frame-src, plus style-src for FramePay.
My order bump isn't showing up.
The product is almost certainly attached as a main product rather than as a bump. Only products marked as bumps are served to the page as bumps.
Move it into the step’s Order bump section in the funnel builder, or send it in the step’s bumps list over the API, which promotes an already-attached product.
Tokenization fails with an unreadable console error.
The console message doesn’t carry the underlying reason. Always render the checkout state’s errors — from onStateChange or onError — into a visible element on the page; the real reason lands there instead.
A common cause: the payment processor requires the buyer’s first and last name to tokenize a card, so a form collecting only email fails until first-name and last-name fields are added.
Are there other SDK attributes I can use besides the documented ones?
No. The author-facing attribute surface is exactly data-cf-element and data-cf-visible.
Any other attribute does nothing, and it is ignored silently — you won’t get a console warning for it.