Unfunnel API
Create, edit, publish and measure funnels from your own code. Everything a
funnel is lives in one JSON document (see the funnel document),
and the API moves that document around.
Base URL: https://sideways-landing-pages.onrender.com
Authentication
Create a key in the app under the ⯠menu, API. The secret is shown once.
Authorization: Bearer tl_live_...
Keys belong to a workspace: every call reads and writes that workspace's
funnels and nothing else. If you are in more than one workspace, a key is
made in whichever one is selected in the app, so check the name in the top
left before creating one. 120 requests a minute per key, and 10 AI calls a
minute per key. Every authenticated call is logged and shown on the API
page.
A key carries the workspace, not your seat in it. It can build, publish
and delete, so an owner, admin or member can create one and a viewer
cannot. A key also keeps working after the person who made it changes
seat or leaves, which is what stops a workspace's automation breaking when
somebody moves on; revoke it on the API page when that is not what you
want. An OAuth connection behaves differently on purpose: membership is
rechecked on every request, so removing somebody cuts their connector off
at once.
A bearer token also works, for clients that sign in with OAuth instead
of holding a key. See Connect Claude. Both
credentials reach the same endpoints with the same permissions; a key is
still the right thing for scripts and cron, since it does not expire and
belongs to no particular person.
Errors are JSON, { "error": "what went wrong" }, with the obvious
status: 400 bad input, 401 bad key, 403 your seat in the workspace does
not allow it, 404 not yours or not there, 422 the document failed
validation (the message names the path), 429 rate limited, 501 AI not
configured on the server.
Endpoints
List funnels
GET /api/v1/funnels
{ "funnels": [ { "id": "...", "name": "...", "slug": "demo-sonnet", "status": "published",
"url": "https://demo-sonnet.unfunnel.page", "created_at": "...", "updated_at": "..." } ] }
Create a funnel
POST /api/v1/funnels
Pass exactly one source:
| body |
what happens |
{ "doc": { ... } } |
your complete document, validated |
{ "template": "lead-magnet" } |
a copy of a built-in template |
{ "prompt": "..." } |
AI drafts the whole document from a description |
Optional with any of them:
"name": "..." overrides the internal name.
"publish": { "slug": "my-page" } goes live in the same call.
"model": "claude-opus-5" picks the model for a prompt. Default is
Sonnet. Only models the server can price are accepted.
Templates: saas-fit, wellness-quiz, appointment, lead-magnet,
recruiting, property-valuation, webinar, coaching.
Returns 201:
{ "funnel": { "id": "...", "name": "...", "slug": "my-page", "status": "published", "url": "https://my-page.unfunnel.page", ... },
"usage": { "model": "claude-sonnet-5", "input_tokens": 2139, "output_tokens": 7582,
"cache_read_tokens": 1987, "cache_write_tokens": 0, "cost_usd": 0.0765 } }
usage is present only for AI creates. A prompt takes 60 to 90 seconds:
the model writes the whole document. A doc or template returns at once.
Read a funnel
GET /api/v1/funnels/:id
Returns the draft document and, when published, the published document
and its version number.
Update the draft
PATCH /api/v1/funnels/:id
| body |
what happens |
{ "doc": { ... } } |
replaces the draft |
{ "instruction": "make the headline shorter" } |
AI edits the draft, returns usage |
{ "name": "..." } |
renames, combinable with either |
Editing the draft does not touch the live version. Publish to push it.
Publish
POST /api/v1/funnels/:id/publish
{ "slug": "my-page" } the first time. Afterwards an empty body
republishes at the existing slug, which is how a script pushes an edit
live. Slugs are 2 to 50 characters, lowercase letters, numbers and
hyphens, unique across the whole platform, and template names are
reserved. Returns { "ok": true, "url": "https://my-page.unfunnel.page", "version": 2 }.
Leads
GET /api/v1/funnels/:id/leads?limit=50&before=<ISO timestamp>
Newest first, each with the visitor's answers and the ad attribution that
produced them (ad_id, adset_id, campaign_id, UTMs). Pass the
returned next_before back as before for the next page.
Preview
GET /v1/funnels/{id}/preview
GET /v1/funnels/{id}/preview?pages=all
GET /v1/funnels/{id}/preview?pages=landing,capture
GET /v1/funnels/{id}/preview?images=false
What the funnel looks like, and what is measurably wrong with it. Works
on a draft, which is the point: look before you publish.
Returns a phone-sized PNG of each page asked for, base64 encoded, plus a
review computed from the document itself: the palette, the type, a WCAG
contrast ratio for every text-on-background pair, a summary of each page,
and findings ranked error, warning, note.
The two halves answer different questions. The image shows composition.
The review catches what an image hides: text at a ratio nobody can read
outdoors, a shape the same colour as the ground it sits on, a second
accent that quietly fell back to the action colour.
pages takes first (the default), all, or a comma separated list of
page ids. Four pages at most per call, since each one is a browser
navigation. images=false returns the review alone, which is instant.
Photographing pages takes a browser and several seconds. If one cannot
start, the call still succeeds: images comes back empty with an
imageError saying why, and the review is unaffected.
{
"funnel": { "id": "...", "name": "...", "slug": "...", "status": "draft" },
"images": [{ "pageId": "landing", "mediaType": "image/png", "base64": "iVBOR..." }],
"review": { "palette": {...}, "typography": {...}, "contrast": [...], "pages": [...], "findings": [...] },
"reviewText": "PALETTE\n ground #0A2E2A\n..."
}
Metrics
GET /api/v1/funnels/:id/metrics?days=30
GET /api/v1/funnels/:id/metrics?days=all
The same report the Metrics page shows: sessions, leads, qualified,
conversion, steps (drop-off per page), creatives (by ad id) and
sources (by utm_source).
days defaults to 30 and is clamped to a year. days=all reaches back
to the beginning, matching the widest option on the Metrics page, where
the window lives in the URL as ?days=7|30|90|all.
Clearing a funnel's metrics
DELETE /v1/funnels/{id}/metrics
DELETE /v1/funnels/{id}/metrics?contacts=true
Erases this funnel's recorded traffic, which is how a funnel walked
through a dozen times during testing starts life with honest numbers.
Sessions and page events always go.
Captured contacts go only with contacts=true. That is deliberate:
traffic is test noise, a lead is a person, and one call should not
quietly do both. Contacts removed this way also disappear from the
Contacts page.
Not reversible, and it touches one funnel inside one workspace.
{ "ok": true, "deleted": { "sessions": 34, "events": 212, "leads": 0 }, "kept": { "leads": 3 } }
In the app the same thing lives on the Metrics page, under the funnel's
report, and needs a member seat or better. A viewer cannot clear a
funnel's metrics.
Images
GET /api/v1/media
POST /api/v1/media
Where the pictures a funnel points at should live: logos, hero photos,
social preview images, favicons. Uploading gives back a public URL to
put straight into a document.
Exactly one source per call:
{ "url": "https://example.com/logo.png" } // copy something already online
{ "data": "iVBORw0KGgo...", "content_type": "image/png" } // base64 bytes, or a data: URI in data
{ "image": { "id": "...", "url": "https://...", "filename": "logo.png", "bytes": 24188 } }
png, jpg, webp, gif or svg, up to 10MB, 30 uploads a minute per key.
Files are stored under the workspace and served with a year of cache,
since the URL of a given upload never changes.
Prefer url over data. Copying an image into the workspace means the
funnel keeps working when whatever hosted the original moves it, and
base64 in a JSON body is four bytes on the wire for every three stored.
GET lists the workspace library, newest first, the same images the
editor's picker shows. DELETE /api/v1/media/:id removes one, object
and row together. Nothing checks whether a funnel still points at it
first, so a deleted image is a broken picture on any page using it.
What the document controls
Everything. brand (name, logo, position, footer, legal), meta
(title, description, social preview image, favicon, both optional:
without them the card is drawn from the funnel's own brand and the tab
icon falls back to the logo, then to a generated mark), theme (eight
colours, radius, answer style, button shape, fonts with extra slots and
per-role rules), settings (progress bar, metaPixelId, metaEvents
mapping funnel moments to Meta standard events, webhooks with up to 3
URLs each for newLead and funnelCompleted, campaignLabel) and
pages, each a stack of blocks with routing rules and scoring.
A booking page is an embed block:
{ "type": "embed", "provider": "calendly", "url": "https://calendly.com/you/intro", "height": 700 }
Providers calendly, cal and ghl prefill the visitor's name, email
and phone from earlier answers automatically, using that provider's own
parameter names, and pass UTMs through. Put the page after the capture
page.
Conversions API credentials are not in the document, since the document
is public. Set them in the app, Connect tab.
Recipes
Template to live page, one call:
curl -X POST $BASE/api/v1/funnels -H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{"template":"lead-magnet","name":"Playbook","publish":{"slug":"playbook"}}'
AI draft to live page, one call:
curl -X POST $BASE/api/v1/funnels -H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{"prompt":"A funnel for a family dental practice in Newport Beach. Goal: book a first cleaning. Disqualify emergency-only visitors. Navy and warm sand.","publish":{"slug":"harborline"}}'
Edit the copy, then push it live:
curl -X PATCH $BASE/api/v1/funnels/$ID -H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{"instruction":"Rewrite every headline to be shorter and more direct"}'
curl -X POST $BASE/api/v1/funnels/$ID/publish -H "Authorization: Bearer $KEY"
Set brand fonts and colours exactly: GET the funnel, change
draft_doc.theme in your code, PATCH it back as doc, publish.
Pull yesterday's leads into a CRM:
curl "$BASE/api/v1/funnels/$ID/leads?limit=200" -H "Authorization: Bearer $KEY"
Connect Claude (MCP)
The API is also an MCP server at /api/mcp, so Claude can build, edit,
publish and measure funnels in your workspace by talking to it. Same
permissions, same request log.
Sign in with OAuth (no key to paste)
Add https://sideways-landing-pages.onrender.com/api/mcp as a custom
connector. Claude discovers the authorization server, registers itself,
and opens a browser: sign in to Unfunnel, pick the workspace to connect,
approve. Nothing is copied by hand and the connection is tied to you
rather than to a shared secret.
One connection covers one workspace, which is the whole reason the
consent screen asks. An access token says who you are, and a person can
belong to several workspaces, so the choice is recorded when you
authorize. To reach a second workspace, connect again and pick it.
Manage connections on the API page under Connected apps. Disconnecting
takes effect on the next request, and so does removing someone from a
workspace: membership is rechecked every time, not trusted from the
moment you authorized.
Or use a key
Claude Code:
claude mcp add --transport http unfunnel https://sideways-landing-pages.onrender.com/api/mcp \
--header "Authorization: Bearer tl_live_..."
Claude Desktop (claude_desktop_config.json), through the mcp-remote
bridge since a header cannot be set on a Desktop connector directly:
{ "mcpServers": { "unfunnel": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://sideways-landing-pages.onrender.com/api/mcp",
"--header", "Authorization: Bearer tl_live_..."] } } }
Tools: list_funnels, get_funnel, list_templates,
funnel_document_guide, create_funnel, update_funnel,
publish_funnel, preview_funnel, get_leads, get_metrics. Resources: the API
reference and the funnel document guide. Ask Claude to "build a funnel
for X and publish it at Y" and it will use them.
The funnel document
One JSON file describes one complete quiz landing page the way
Perspective structures a funnel: a funnel is a sequence of pages,
and a page is a scrollable stack of blocks. A landing page and a
quiz step are the same thing, they just stack different blocks. The
renderer at /f/[slug] is generic: if two funnels differ, they differ
in their documents, never in code.
A document is validated every time it is written through the API or the
editor. A bad one is rejected with a 422 whose message names the exact
path that failed, for example pages.3.next.default.
Two built-in templates show the patterns in full. Create either with
POST /api/v1/funnels and { "template": "wellness-quiz" } or
{ "template": "saas-fit" }, then GET it back to read the document.
wellness-quiz: the Perspective pattern. Long-form landing page
with an embedded first question, emoji tile grids, filled option
buttons, a multi-select, a measurements form, the "calculating your
result" loader, an email capture, and a personalized result page.
saas-fit: B2B SaaS pattern. Long-form landing page with an
embedded qualifier, scored questions, a spend-based disqualification
branch, a fit-check loader, and a work-email capture.
On phones every page is a single column. On desktop, a top page whose
copy is followed by an image or video block renders as a split hero,
copy on the left and the media on the right, with the remaining blocks
below at full width. Quiz steps and forms stay in a centred card.
Top level
{
"schemaVersion": 2,
"name": "Halewell metabolism quiz", // internal, shows in reporting
"brand": { ... },
"meta": { "title": "...", "description": "..." }, // the <head>
"theme": { ... },
"settings": { ... },
"pages": [ ... ]
}
brand
{
"name": "Halewell", // wordmark fallback when no logoUrl
"logoUrl": "/funnels/x/logo.png",
"logoPosition": "left", // left (Perspective default) | center
"footer": "Halewell Health, Austin, TX.", // small print, end pages
"legal": "Not affiliated with..." // finer print, end pages
}
theme
Eight colors, a radius scale, an option style, a button style, and two
Google Fonts families. Any CSS color format works.
{
"ground": "#F7F4ED", // page background
"surface": "#FFFFFF", // cards and inputs
"surfaceHi": "#E9E4D8", // progress track
"ink": "#22271F", // body text
"inkMuted": "#6B7263", // secondary text
"line": "#E2DCCC", // hairline borders
"accent": "#33707E", // buttons, progress fill, active states
"accentInk": "#FFFFFF", // text on top of accent
"accentAlt": "#C7E14A", // optional second colour, decoration only
"radius": "lg", // sm | md | lg | xl
"optionStyle": "filled", // filled (Perspective) | card
"button": { "shape": "rounded", "caps": false },
"fonts": { "display": "Lora", "body": "Inter", "displayWeight": 600 }
}
accentAlt is for decoration and nothing else. Actions stay one colour,
which is what keeps "this is tappable" unambiguous while a page still
gets to use more than one hue. Left out, it falls back to accent.
Emphasis
Wrap words in [[double brackets]] anywhere in a heading, subtext or
eyebrow, and they take the treatment defined once on the theme:
"emphasis": {
"font": "script", // "display", "body", or an extra font slot id. Absent: inherits
"color": "accent", // accent | accentAlt | ink | inkMuted | ground | accentInk
"italic": true,
"weight": 700, // optional override
"highlight": "none" // none | accent | accentAlt | surface | surfaceHi
}
Defining it on the theme rather than in the copy is deliberate. A
headline that mixes a script face with a block of colour is one design
decision, not a styling instruction repeated in every string, and a
model writing copy only has to mark which words carry the idea.
A highlight other than none paints a block of colour behind the
words, the marker-pen look, and wraps correctly onto a second line. With
one set, color has to read against that block rather than against the
page: ground and accentInk exist for exactly that, since on a dark
theme every other option is light and so is the highlight.
[[]] with nothing inside disappears; an unclosed [[ is left alone
rather than guessed at, so a half-typed marker never eats a paragraph.
The theme lands as --fn-* CSS variables on the funnel root. Funnel
components only reference those variables, which is the mechanism that
lets one renderer serve every brand.
More than two fonts
A brand that uses three or more faces declares extra font slots and
says where each one lands. Both fields are optional; without them,
headings take the display font and everything else takes the body font,
exactly as before.
"fonts": {
"display": "Fraunces", "body": "Inter", "displayWeight": 600,
"extra": [{ "id": "mono", "family": "Space Grotesk", "weight": 500 }]
},
"typography": { "button": "mono", "eyebrow": "mono" }
typography accepts the roles heading, body, button and
eyebrow. Each value is "display", "body", or the id of an extra
slot; anything else fails validation at parse time, the same way a bad
routing target does. Slot weights are loaded from Google Fonts along
with everything else in one stylesheet request.
settings
{
"progress": "bottom", // bottom (Perspective) | top | none
"resumeOnReturn": true, // sessionStorage resume after refresh
"campaignLabel": "halewell_metabolism_quiz", // stamped on payloads
"metaPixelId": "1234567890", // this funnel's browser pixel
"metaEvents": { "lead": "Lead", "qualified": "CompleteRegistration" },
"webhooks": { // server-fired, up to 3 URLs each
"newLead": ["https://hooks.zapier.com/..."],
"funnelCompleted": []
}
}
Webhooks are how leads reach your CRM: newLead fires when a form
converts and funnelCompleted when a visitor reaches an end page. The
payload carries every answer, the score, the UTM and ad taxonomy
params, and meta_event_id so the CRM row can be joined to the Meta
Lead event. metaEvents maps funnel moments (landing, step,
lead, qualified, disqualified) to Meta standard events, or off.
compliance
Three independent switches, all off by default, edited on the
editor's Compliance tab or written here.
"compliance": {
"cookieBanner": {
"enabled": true,
"mode": "notice", // notice: inform, track at once (US)
// optIn: pixel and Meta cookies wait for Accept
"text": "...", // blank = built-in wording
"acceptLabel": "Got it", "declineLabel": "Decline"
},
"privacyUrl": "https://yourbrand.com/privacy",
"termsUrl": "https://yourbrand.com/terms",
"smsConsent": { "enabled": true, "required": true, "text": "..." }
}
privacyUrl and termsUrl appear as a footer row on every page, in
the banner, and after consent wording. smsConsent puts a TCPA
checkbox on every lead form that has no consent of its own; blank
text uses built-in express-consent wording with the brand name. When
a visitor ticks it, the lead carries sms_consent: true plus
sms_consent_text, sms_consent_at, sms_consent_ip,
sms_consent_user_agent and sms_consent_url, in the lead row and
in every webhook payload. In optIn mode, Unfunnel's own step events
still fire (first-party, needed to run the funnel) but the Meta pixel,
the _fbc and _fbp cookies and the Conversions API leg wait for
Accept, and Decline keeps them off for that visitor.
Pages
{
"id": "landing",
"layout": "top", // top: scrollable long-form
// center: quiz step, vertically centered
// hero: full-bleed image with a glass card
"align": "left", // center (default) | left
"decorations": [ // max 4, optional
{ "shape": "circle", "color": "accentAlt", "size": "lg",
"position": "top-right", "opacity": 1 }
],
"background": { "imageUrl": "...", "overlay": 0.8 }, // hero only
"blocks": [ ... ],
"next": { "default": "gender" },
"autoAdvance": { "afterMs": 2800 }, // loader page, spinner + auto next
"outcome": "qualified" // terminal marker, see below
}
- A page with
autoAdvance renders a spinner above its blocks and
advances by itself: the Perspective "your result is being calculated"
pattern.
- A page with
outcome is terminal. qualified fires
CompleteRegistration once per session; disqualified does not. Brand
footer and legal text render on terminal pages.
align decides which edge the copy hangs off. Centre reads as a form
and suits a question; left reads as a designed page and suits a
landing page or a result. Most funnels centre the questions and leave
the landing page left.
decorations are blocks of colour behind the copy: the circle
bleeding off a corner, the square under a headline. Shapes are placed
by keyword rather than by coordinate, so there is no combination that
breaks a layout. They are clipped to the page, never interactive and
hidden from screen readers. Colours are palette references only; a
page never invents one. Question pages usually want none, because
nothing should compete with the question.
- Validation rejects dead ends: every non-terminal page needs
next
plus a block that can trigger navigation, and every route target must
exist.
Blocks
Presentational: eyebrow, heading (sizes md, lg, xl), subtext,
badge, stats, image, video (file or YouTube/Vimeo embed),
list (checkmarked), testimonial, quote (founder pull quote),
features (emoji feature cards), logos (customer or press strip),
spacer, divider, legal.
A logos block is the "trusted by" row under a hero. Items are names
set in the display face, or logo images when imageUrl is given. With
marquee: true (the default) the strip scrolls continuously; false
wraps the items into a static centred row. tone: "paper" puts the
strip on a light card: full-colour brand marks are unreadable on a dark
theme, and a customer's logo colours are theirs to keep, so this is the
one block allowed a surface the theme does not define. Leave it out (or
"ink") for the strip to sit on the page.
{ "type": "logos", "heading": "Trusted by", "marquee": true, "tone": "paper",
"items": [{ "name": "Acme" }, { "name": "Globex", "imageUrl": "https://..." }] }
Text in eyebrow, heading and subtext supports {{field}}
interpolation from collected answers: "{{first_name}}, your result is
on its way."
Interactive:
choice
{
"type": "choice",
"field": "body_type", // key the answer is stored under
"variant": "grid", // grid: 2-col tiles | list: stacked rows
"multiple": false, // true shows checkboxes + Continue button
"options": [
{ "value": "apple", "label": "Apple shape",
"sublabel": "Weight around the midsection",
"emoji": "š", // or "imageUrl": "..."
"score": 5 }
]
}
Single choice advances on tap. Multiple choice toggles and shows a
submitLabel button. Whether options render as bordered cards or solid
accent tiles comes from theme.optionStyle, not from the block.
form
{
"type": "form",
"action": "lead", // lead: fires Lead + webhook | continue
"fields": [
{ "field": "first_name", "kind": "name", "placeholder": "Your first name...", "emoji": "š" },
{ "field": "email", "kind": "email", "placeholder": "Your email...", "emoji": "āļø" }
],
"consent": { "field": "sms_consent", "text": "I agree to...", "required": true },
"submit": { "label": "Get the results", "loadingLabel": "Sending...", "note": "No spam." },
"skip": { "label": "I'm not sure", "field": "model_reference", "storeValue": "not sure" }
}
Field kinds: text, email, tel (auto formats US numbers),
number, name. Use action: "continue" for mid-funnel field pages
like measurements; only action: "lead" fires the Lead event.
cta
{ "type": "cta", "label": "Start the test now", "note": "Takes 2 minutes" }
{ "type": "cta", "label": "Call us", "href": "tel:9499031698", "style": "ghost" }
Without href it advances through the page's routing. With href it
is a plain link: tel:, sms:, or https:.
embed
{ "type": "embed", "url": "https://calendar...", "prefill": ["email", "phone"], "height": 640 }
Iframe for GHL, Calendly or cal.com booking calendars. With a named
provider the visitor's name, email and phone from earlier answers are
filled into the calendar automatically; prefill appends extra answer
fields as query params.
A booking page needs no button. When the visitor completes the booking,
the calendar tells the page and it advances through next by itself,
so put the confirmation page in next.default.
Routing and scoring
"next": {
"rules": [
{ "when": { "field": "homeowner", "op": "eq", "value": "rent" }, "goto": "not-fit" },
{ "when": { "field": "any", "op": "scoreGte", "value": 35 }, "goto": "strong-fit" }
],
"default": "next-question"
}
Rules evaluate in order, first match wins, falling through to
default. Operators: eq, neq, in, notIn, gt, lt,
contains (multi-select), scoreGte, tallyMax. The score is the
sum of the score values on every selected option, recomputed from
answers so back navigation cannot double count.
A "which of these is your team" quiz has no threshold: each answer
points at one of several outcomes and the outcome with the most
answers wins. Give each option a tally (the outcome's name) and,
optionally, a tallyWeight so a summing-up question counts double.
Route on the leader with tallyMax, one rule per outcome:
"options": [
{ "value": "wait", "label": "People wait for the leader to decide", "tally": "dependency" },
{ "value": "unclear", "label": "Nobody is sure who owns it", "tally": "ownership" }
]
...
"rules": [
{ "when": { "field": "any", "op": "tallyMax", "value": "dependency" }, "goto": "result-dependency" },
{ "when": { "field": "any", "op": "tallyMax", "value": "ownership" }, "goto": "result-ownership" }
]
A dead heat goes to whichever outcome is listed first, so order the
rules by which result you would rather show when it is close. A tally
no answer counted toward never wins, and the page default still
covers a visitor who somehow answered nothing.
Tracking
The renderer reuses src/lib/meta/pixel.ts untouched.
| Moment |
Event |
| Funnel loaded |
PageView (fired by the root layout) |
| Page completed |
a custom event named for the page, see below |
| Outbound button tapped |
a custom event named for the page, _Clicked |
| Lead form submitted |
Lead, dual leg, with hashed email and phone |
| Qualified end reached |
CompleteRegistration, guarded against refires |
Named step events
Every page sends one custom event when a visitor completes it (moves
forward from it), named from the page id so a media buyer can build a
custom conversion without opening the editor:
| Page |
Default event |
| first page |
QuizStart |
roster-size with a choice block |
RosterSize_Answered |
contact with a form block |
Contact_Submitted |
book with a booking embed |
Book_Booked |
| any other page with a button |
{PascalId}_Completed |
| loader or end page |
nothing |
The event carries content_name (the page id), content_category
(the funnel slug), next_page, and answer when the page was a
question. A button with an href sends {PascalId}_Clicked on the
tap, since the visitor is leaving the funnel.
Override either with an event field: "event": "Q2_Answered" on a
page, or on a cta block. "event": "off" sends nothing. Names are
letters, numbers and underscores, 40 characters at most. The step
entry in settings.metaEvents still switches the whole set to a
standard event or off.
Building a funnel
Start from a template (GET it back after creating) or write the
document from scratch following this page.
Set brand, theme, meta, and pages. Keep field keys snake_case
and stable, they become lead columns and CRM fields.
Put images in the workspace with POST /api/v1/media and reference the
URLs it returns: brand.logoUrl, meta.image, image blocks and hero
background.imageUrl. Any public URL works, but a copy you own keeps
working when whatever hosted the original moves it.
meta.image and meta.favicon are optional. Without them the link
preview is drawn from the funnel itself (its colours, font, logo and
headline) and the tab icon falls back to the logo, then to a mark made
from the brand initial. Set them when the brand has real artwork.
POST /api/v1/funnels with { "doc": ... }. A 422 names the exact
path that failed; fix it and post again.
Publish with a slug, open /f/<slug>, and walk every branch on a
phone and a desktop before sending traffic.