Unfunnel is live: every feature is free while we are in testing. Start free ›

Developers

Build funnels from your own code

Everything a funnel is lives in one JSON document, and the API moves that document around. Create a key in the app under the menu, API, then start with the reference below.

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

  1. Start from a template (GET it back after creating) or write the document from scratch following this page.

  2. Set brand, theme, meta, and pages. Keep field keys snake_case and stable, they become lead columns and CRM fields.

  3. 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.

  4. POST /api/v1/funnels with { "doc": ... }. A 422 names the exact path that failed; fix it and post again.

  5. Publish with a slug, open /f/<slug>, and walk every branch on a phone and a desktop before sending traffic.