Funnels for ads on Meta, Google, TikTok and LinkedIn. Free to start. Start free ›

Developers

Just want Unfunnel inside Claude? You do not need any of this. Connect it in about a minute.

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. The same operations are tools on the MCP server at https://unfunnel.io/api/mcp, for Claude and other AI agents.

For tools and agents: openapi.json, api.md, funnel-document.md, llms.txt.

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://unfunnel.io

Plan: API keys are part of Growth and up. On Free and Launch a key cannot be created, and a key's calls answer 402 with "upgrade": true. The Claude connector (an OAuth token, see below) works on every plan. Whichever way a call arrives, the workspace's plan still applies: AI drafting and editing need Launch, brand fonts need Growth, and live funnel limits hold.

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. Keys need Growth or up, so key calls get 120 requests a minute, and AI runs are capped at 3 at once and 60 an hour. None of these is per key: the 3 at once is per workspace, the others per workspace and per person. The full list is under the errors below. 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.

Rate limits are per workspace and per person, and a 429 says how long to wait in its Retry-After header (seconds) and in retry_after in the body. Requests, the Claude connector's included: Free 20 a minute and 1,000 a day, Launch 60 a minute, Growth and up 120 a minute. AI runs (a prompt or an instruction): Free one at a time and 5 an hour, paid plans 3 at a time and 60 an hour. Publishing: 20 an hour per workspace. Free creates up to 10 funnels a day.

Any POST can carry an Idempotency-Key header (any unique string up to 200 characters, a uuid is ideal). If the request is repeated with the same key within a day, for instance after a timeout on a minutes-long AI create, the first answer comes back (with Idempotent-Replayed: true) instead of a second funnel and a second AI charge. While the first request is still running the repeat gets a 409 with Retry-After; the same key with a different body is a 422. Failures on our side (5xx) and rate limits are not kept, so retrying those really retries.

AI on Free is the AI to build a first funnel: once a workspace has used it, prompt and instruction answer 402 with upgrade: true, while doc, template and landing keep working on every plan. That is how Claude keeps building and editing a Free workspace's funnels: it writes the document itself.

Endpoints

Workspace

GET /api/v1/workspace

Which workspace the credential acts on, its plan, what the plan includes, and how much of each limit is used. Call it first: it is how a script or Claude knows that custom domains need Launch, or that the workspace already has its one live funnel, before a call fails.

{ "workspace": { "id": "...", "name": "Harbor Dental", "logo_url": null },
  "plan": { "id": "launch", "name": "Launch" },
  "connection": { "via": "api_key", "key_id": "..." },
  "features": { "customDomain": { "enabled": true, "plan": "launch" }, "capi": { ... }, "customSlug": { ... }, ... },
  "limits": { "live_funnels": 3, "leads_per_month": null, "seats": 1, "requests_per_minute": 60, "publishes_per_hour": 20, ... },
  "usage": { "live_funnels": 1, "funnels": 4, "leads_this_month": 37, "seats": 1, "domains": 1 },
  "ai": { "kind": "spend_caps", "available": true, "used_pct_24h": 13, "used_pct_31d": 16 } }

A null limit means no limit. Leads are unlimited on every plan, so leads_per_month is always null.

AI on a paid plan has an allowance for any 31 days and a smaller one for any 24 hours, shared by every workspace billed to the same plan. used_pct_24h and used_pct_31d say how much of each is used, from 0 to 100. A run that would pass either is not started (429), and a run in progress stops before it could pass its share. Free shows "kind": "free_allowance" instead.

List funnels

GET /api/v1/funnels
{ "funnels": [ { "id": "...", "name": "...", "slug": "demo-sonnet", "status": "published",
                 "url": "https://demo-sonnet.unfunnel.page", "live_url": "https://quiz.brand.com",
                 "domain": { "hostname": "quiz.brand.com", "verified": true },
                 "created_at": "...", "updated_at": "..." } ] }

url is always the funnel's own link. live_url is where it actually answers: its verified custom domain when it has one. Both are null while the funnel is not live. GET /api/v1/funnels/:id carries the same three fields.

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": "...", "website": "https://brand.com" } AI drafts the whole document from a description, in the brand read from its site

A prompt needs a brand: website (read with a real browser for its logo, colours and fonts, and kept as the workspace's brand kit; see Brand kits) or, for a brand with no site, "brand_colors": { "accent": "#0F766E", "ground": "#FBFAF7", "ink": "#102A26", "logo_url": "https://..." }. Without either the call is a 400. The model writes the copy and chooses the blocks; the theme, fonts and logo come from the brand and nothing the model writes changes them. A template takes website too, and is then put in the brand's look. A prompt or template draft with no photo under its first button gets one made: the funnel's first question in a phone on a soft brand tint, stored in the workspace.

With a doc or a template, "landing": { ... } is the landing page as a plan of words (headline, offer, button, proof, sections; see the funnel document guide) and is laid out by Unfunnel in place of the document's landing page. PATCH /api/v1/funnels/:id takes landing too, alone or with doc.

Optional with any of them:

  • "name": "..." overrides the internal name.
  • A field the call does not take is refused (422) with the valid ones listed, never ignored.
  • "publish": { "slug": "my-page" } goes live in the same call. Refused (422, with publish_error, fixes and the draft still created) while a blocking fix is open, unless "publish": { "slug": "my-page", "force": true }.
  • "model": "claude-opus-5" picks the model for a prompt. Default is Sonnet 5.5. Only models the server can price are accepted.

Templates: b2b-free-offer, 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", ... } }

The response may also carry fixes, the changes the page still needs:

"fixes": [ { "path": "pages.0.blocks.3", "problem": "...", "fix": "...", "blocking": true } ]

An AI create also carries qa, the visual check: the pages are photographed and judged beside the brand's own site, and revised up to twice if they fall short.

"qa": { "passed": true, "revisions": 1 }
"qa": { "passed": false, "revisions": 2, "issues": ["landing: The first screen is crowded. Cut the second subtext."] }

A prompt takes one to three minutes. A doc or template returns in seconds.

A document key the format does not have is a 422 naming the path and the valid keys, never a silent drop. A few obvious ones are renamed instead and listed under renamed (a quote's name becomes attribution).

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 in its own look (new colours only when asked), returns edit (summary, changed, and cannot for anything it could not do) and qa
{ "name": "..." } renames, combinable with either

Editing the draft does not touch the live version. Publish to push it. The response carries any fixes still open on the draft.

An instruction (or a landing plan without doc) is applied to the draft as it was when the request arrived. If anything else saves the draft before it finishes, nothing is written and the answer is 409 with the changed version in draft_doc: send it back as doc to keep it, or ask again.

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

While the draft has a blocking fix open the publish is refused with a 422 carrying the fixes. { "force": true } publishes anyway; the response then says "forced": true and the publish is written to the workspace's audit trail.

Unpublish

DELETE /api/v1/funnels/:id/publish

Takes the funnel offline without deleting it. Its link and any custom domain stop serving and stop taking leads on the next request. The draft, slug, version history, domain, leads and metrics are kept, so a later POST /publish with an empty body puts it back at the same link. Returns { "ok": true, "status": "draft", "wasLive": true, "slug": "my-page" }. Unpublishing a funnel that is not live is not an error ("wasLive": false).

Versions and rolling back

GET  /api/v1/funnels/:id/versions
GET  /api/v1/funnels/:id/versions/:n
POST /api/v1/funnels/:id/publish     { "version": 3 }

Every publish is kept as a numbered version. The list says which one is live; /versions/:n returns that version's whole document.

Publishing with version puts that version live again, as a new version, so a rollback can itself be rolled back. It was live once, so open design fixes do not hold it up; moderation and the hourly publish limit still apply. Restoring also makes that version the draft, so it is refused with a 409 while the draft has edits that are not live, unless you pass "discard_draft": true. The answer carries restored_from.

Delete a funnel

DELETE /api/v1/funnels/:id
DELETE /api/v1/funnels/:id?force=true

Deletes the funnel for good, with its versions, sessions, events and leads. There is no undo. A live funnel is refused with a 409 unless force=true; unpublishing first is the gentler way. A custom domain that pointed at it is removed with it, here and on the host, and is listed under removed_domains; connect it to another funnel to use it again. (Before 0055 it stayed in the workspace unattached, serving a 404 on the customer's own domain, as detached_domains.)

{ "ok": true, "deleted": { "funnel": "...", "leads": 12 }, "removed_domains": ["quiz.brand.com"] }
GET /api/v1/slugs/:slug

Whether a funnel can be published at https://<slug>.unfunnel.page. Links are unique across all of Unfunnel. available is true or false, with reason one of invalid, reserved, taken or yours (this workspace already uses it, which is fine for that same funnel).

Choosing a link is part of Launch and up (can_choose). On Free, a funnel's link is made from its name plus four characters, and a publish that asks for a different one is published at the made link instead, with slug_note in the answer saying so.

Templates

GET /api/v1/templates

The built-in templates POST /funnels { "template": ... } accepts.

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.

Every funnel's leads at once:

GET /api/v1/leads?limit=100&before=<ISO timestamp>
GET /api/v1/leads?after=<ISO timestamp>
GET /api/v1/leads?funnel_id=...&status=won

before pages newest first. after returns leads created after a point, oldest first, which is how a CRM sync catches up: keep the next_after from each run and pass it next time (it stays put when nothing new arrived; has_more says to call again now). Test visits are left out unless test=true, or test=all for both. Each lead carries funnel_id and funnel_name.

GET    /api/v1/leads/:id
PATCH  /api/v1/leads/:id     { "status": "contacted", "notes": "Booked for Tuesday" }
DELETE /api/v1/leads/:id

status is one of new, contacted, qualified, won, lost, the same as the Contacts page, so a CRM can write its outcome back. Deleting is permanent and goes in the workspace's audit trail.

Preview

GET /api/v1/funnels/{id}/preview
GET /api/v1/funnels/{id}/preview?pages=all
GET /api/v1/funnels/{id}/preview?pages=landing,capture
GET /api/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), platforms (Meta Ads, ChatGPT Ads, Direct and so on, named as the lead webhook's traffic_source), creatives (by ad: Meta's ad_id, or utm_content where a platform has no ad id macro, as ChatGPT Ads does), campaigns and sources (raw utm_source). Each platform, creative and campaign row carries platform; creatives also carry detail (the id behind a named ad), and platform rows engaged (percent past the first page). Also timeOnPage (median seconds on a page; each step carries its own seconds, and outcome for end pages), activity (168 counts of sessions started per UTC hour of the week, Sunday midnight first), devices with devicesKnown, and answers: one entry per choice question with each option's count and pct, and source saying whether every visitor's answer was counted or only leads'.

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.

Each step's dropFromPrev is the share of the previous step's visitors who reached neither this page nor any later one, and fromPrev is how many of them also reached this page during the same visit. In a funnel that branches, a page some visitors skip is not counted as losing them.

incomplete: true means the window held more rows than a report reads (20,000 events or leads), so it was built from the most recent part. Ask for fewer days.

Clearing a funnel's metrics

DELETE /api/v1/funnels/{id}/metrics
DELETE /api/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.

A/B tests

Growth and up. A test runs two to four versions of one live funnel against each other. Version a is the original: the funnel itself, edited and published as usual. Versions b to d each have their own document. A version changes the page only (pages, copy, design, brand); the funnel's name, sharing card and settings (pixels, server events, cookie banner, consent, webhooks) are the same whichever version a visitor sees.

Each visitor is assigned a version by a hash of the test and their visitor id, weighted by the split, and kept on it by a cookie once their consent allows cookies. Their session, events and lead are recorded with the version, so the Metrics page and GET .../metrics can be read per version. Bots and test-link visits are labelled and left out as everywhere else.

GET    /api/v1/funnels/:id/ab-test            the open test, else the last to end, with results
GET    /api/v1/funnels/:id/ab-test?docs=b     plus version b's draft and live documents
POST   /api/v1/funnels/:id/ab-test            { "versions": 2 }   set one up (2 to 4)
PATCH  /api/v1/funnels/:id/ab-test            change it, see below
DELETE /api/v1/funnels/:id/ab-test            end it, no winner, the page is untouched
POST   /api/v1/funnels/:id/ab-test/winner     { "variant": "b" }  make b the live funnel and end

POST makes version B (and C, D) as copies of the live page with an even split. Nothing is served until the test is running. The funnel has to be published, and has at most one open test.

PATCH applies whatever it is given, in this order, and stops at the first refusal:

{
  "add_variant": true,                    // one more copy of the live page, before it starts
  "docs": { "b": { /* full document */ } }, // version drafts; unknown keys are refused, as on PATCH /funnels/:id
  "weights": { "a": 50, "b": 50 },        // 0 to 100 each, relative; people already in the test keep their version
  "publish": true,                        // or ["b"]: drafts go to that version's visitors
  "status": "running"                     // or "paused" (everybody sees the original). Starting publishes every draft
}

A version's document is screened exactly as a publish is (moderation, workspace-only fonts and videos, a confirmed email).

POST .../winner publishes the winning version as a new version of the funnel, with the funnel's own settings, replacing the draft, and ends the test. "variant": "a" keeps the original. Ended tests and their numbers stay readable.

{
  "test": {
    "id": "...", "funnel_id": "...", "status": "running", "winner": null,
    "started_at": "2026-10-20T14:00:00Z", "ended_at": null,
    "variants": [
      { "key": "a", "label": "Original", "weight": 50, "percent": 50, "unpublished_changes": false },
      { "key": "b", "label": "Version B", "weight": 50, "percent": 50, "unpublished_changes": false }
    ],
    "results": {
      "goal": "leads",
      "summary": "Version B is winning: a 98% chance it converts better than the original.",
      "variants": [
        { "key": "a", "visitors": 1000, "completions": 410, "leads": 100, "qualified": 61, "conversion_rate": 0.1 },
        { "key": "b", "visitors": 1000, "completions": 455, "leads": 130, "qualified": 80, "conversion_rate": 0.13,
          "lift": 0.3, "probability_to_beat_original": 0.982, "p_value": 0.036, "verdict": "winning",
          "sentence": "Version B is winning: a 98% chance it converts better than the original." }
      ]
    }
  }
}

goal is leads when the funnel has a form and completions otherwise. probability_to_beat_original is Bayesian (Beta posteriors on each version's rate); p_value is a two-proportion z-test, for anyone who wants the classical figure. verdict is not-enough-data until each version has 100 visitors and the two together have 10 conversions, then winning or losing past 95% either way, else too-close.

Below Growth, anything that starts or changes what visitors see answers 402 with upgrade: true; reading and ending still work. A downgrade ends every running test. MCP has the same as get_ab_test, create_ab_test, update_ab_test and end_ab_test.

POST /api/v1/funnels/:id/test-link
{ "metaTestCode": "TEST12345", "sendLeads": false }

A link that opens the live funnel as a test visit, which is the better way to walk a funnel before launch than clearing its metrics afterwards. Only the visit opened from the link is a test:

  • its session, events and lead are marked test and left out of every report and of the monthly lead allowance. The lead still shows under Contacts, with a Test badge
  • a test lead stays in Unfunnel: no webhook and no lead email, so no CRM workflow runs for it and none needs a branch to leave tests out. With sendLeads: true it goes everywhere a real lead goes, carrying test: true (the field is always present, false for real visitors), for checking the CRM side end to end
  • server events go to each platform's test channel: ChatGPT's validate-only mode, and Meta's Test Events tab when metaTestCode is given. Without a code, Meta's server copies are held back
  • browser pixels fire as normal, so Meta Data Advisor and Pixel Helper see them
  • a panel on the page lists every event as it fires, with what each platform's server answered

The link works for 24 hours and only on this funnel. The funnel has to be published. Both fields are optional.

{ "ok": true, "url": "https://quiz.example.com/?uf_test=...", "expiresAt": "2026-09-27T14:00:00.000Z" }

In the app it is under Tracking, Test mode.

Custom domains

GET    /api/v1/domains
POST   /api/v1/domains                     { "hostname": "quiz.brand.com", "funnel_id": "..." }
GET    /api/v1/domains/:hostname
POST   /api/v1/domains/:hostname/verify
DELETE /api/v1/domains/:hostname

A funnel can answer on a domain the customer owns. Launch and up.

Connecting saves the mapping and answers with dns, the records to create at the domain's DNS provider. That is the one step the API cannot do for you. A subdomain takes a single CNAME, which both routes the traffic and proves ownership; an apex domain takes an A record, a www CNAME and a TXT record.

{ "outcome": "connected",
  "domain": { "hostname": "quiz.brand.com", "funnel_id": "...", "status": "pending_dns", "verified": false,
              "url": "https://quiz.brand.com",
              "dns": [ { "type": "CNAME", "name": "quiz", "value": "3f9c...e1.cname.unfunnel.io" } ],
              "render": { "configured": true, "registered": true, "verified": false } },
  "next": "Create the records in dns at the domain's DNS provider, then call POST /api/v1/domains/{hostname}/verify. ..." }

Then call verify once the records exist. It checks now and answers verified, and while false, missing says what is still wrong ("No DNS record found", "points at X, not Y", "the ownership record is not visible yet"). DNS takes minutes to an hour to show up, so polling it every minute or two is fine. A verified domain serves the funnel as long as the funnel is published.

A hostname already showing another of the workspace's funnels is a 409 naming that funnel, unless the body has "move": true. One connected to a different Unfunnel workspace is a 409 too. DELETE disconnects it; the funnel keeps its unfunnel.page link, but the domain stops answering.

Server-side tracking

GET    /api/v1/funnels/:id/tracking
PUT    /api/v1/funnels/:id/tracking/meta     { "access_token": "EAAB...", "dataset_id": "1234567890" }
PUT    /api/v1/funnels/:id/tracking/openai   { "api_key": "...", "pixel_id": "..." }
POST   /api/v1/funnels/:id/tracking/google/connect
PUT    /api/v1/funnels/:id/tracking/google   { "customer_id": "123-456-7890", "categories": ["submit_lead_form"] }
DELETE /api/v1/funnels/:id/tracking/:platform

The browser pixels live in the document (settings.metaPixelId, settings.openaiPixelId). The server half, the Meta Conversions API token and the ChatGPT Ads key, is a secret, so it is stored here instead: encrypted, and never returned, only its last four characters. Launch and up.

dataset_id and pixel_id default to the document's own pixel ids, so usually only the token is needed. If the Meta dataset you pass differs from the document's pixel the answer carries a warning, since the browser and server copies of an event only deduplicate in one dataset.

Google Ads has no key to paste. A person signs in with Google once: POST .../tracking/google/connect answers a sign-in url (15 minutes) to open in a browser where they are signed in to Unfunnel as the key's owner. After they approve, GET /tracking lists the ad accounts that login reaches under google.server.accounts, and PUT .../tracking/google with customer_id picks one and creates a conversion action for each Google category the draft sends (or the categories given). It writes settings.googleAdsId and settings.googleConversions into the draft, so the next publish puts the Google tag live, and the server sends every conversion again through Google's Data Manager API with the click id and hashed contact details, under the id the tag used, so Google counts it once. Run it again after choosing more Google events: existing actions are reused. A warning says when the account has not accepted Google's customer data terms, which enhanced conversions need (Google Ads, Goals, Settings).

GET /tracking shows what is set and the last server events with each platform's reply, which is how to tell a working token from a dead one:

{ "meta": { "pixel_id": "1234567890", "server": { "configured": true, "dataset_id": "1234567890", "token_last4": "Zx9Q" } },
  "openai": { "pixel_id": null, "server": { "configured": false } },
  "google": { "tag_id": "AW-123456789", "conversion_labels": { "submit_lead_form": "AbCdEfGh123" },
              "server": { "available": true, "connected": true, "signed_in_as": "ads@brand.com",
                          "account": { "id": "1234567890", "name": "Brand" }, "enhanced_conversions": true } },
  "deliveries": [ { "occurred_at": "...", "moment": "lead", "event_id": "...", "results": { "meta": { "event": "Lead", "ok": true } } } ] }

Brand kits

POST /api/v1/brand     { "url": "https://brand.com", "refresh": false }
GET  /api/v1/brand

Reads a website with a real browser at phone and desktop width and keeps what it finds as the workspace's brand kit: the logo (the mark in the header's link home, else anything named logo, else the touch icon, never the social share image), drawn to a transparent PNG and stored in the workspace; the page, text and button colours and the colour the site uses most; the heading and body faces, matched to Google Fonts (every family Google serves, else the closest free face; guess: true when the name said nothing about the face). notes names a face only when somebody could upload it: a system font such as Helvetica gets none. A site read before comes back from the store unless refresh is true. 6 reads a minute per key.

{ "brand_kit": { "host": "brand.com", "name": "Brand", "logo": { "url": "https://...", "source": "header" },
                 "colors": { "background": "#FFFFFF", "text": "#111827", "button": "#0F766E", ... },
                 "fonts": { "heading": { "family": "Gotham", "google": "Montserrat", "matched": false, "weight": 700 }, ... } },
  "theme": { ... },          // the funnel theme built from it, contrast-checked, ready to use
  "read_now": true,
  "notes": ["The heading face Gotham is not on Google Fonts; Montserrat stands in. ..."] }

A document written by hand should use theme as it is, the kit's logo as brand.logoUrl and the site as brand.website: a theme that drifts from the kit's colours is a blocking fix.

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.

A file on your own machine goes up through an upload link instead:

POST /api/v1/media/uploads            { "filename": "founder.mp4", "content_type": "video/mp4" }
PUT  <upload_url>                     the file's bytes, with that Content-Type
POST /api/v1/media/uploads/complete   { "path": "<path from the first call>" }

The link is good for two hours and takes png, jpg, webp or gif up to 10MB, or mp4 or webm up to 100MB. complete checks what arrived and adds it to the library the same way any upload is added, and returns its url (a video also gets poster_url).

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.

Videos

POST /api/v1/media                     { "kind": "video", "url": "https://example.com/intro.mp4" }
POST /api/v1/media                     multipart/form-data, the file in a field named "file"
GET  /api/v1/media?kind=video

MP4 or WebM, up to 100MB, by URL or as a multipart upload (not base64). The file's own bytes decide the format. The answer:

{ "url": "https://.../videos/<org>/...-intro.mp4", "id": "...", "bytes": 4194304,
  "duration_seconds": 12.4, "width": 1080, "height": 1920,
  "poster_url": "https://.../videos/<org>/...-intro-poster.jpg",   // null when no frame could be made
  "video": { ...the library row, "kind": "video" } }

Duration and size are read from the file itself. The poster is a JPEG taken a second in, made with ffmpeg (bundled; FFMPEG_PATH overrides it). Files are served publicly with a year of cache and support range requests, so browsers stream and seek. The Supabase project's own upload ceiling applies too (50MB on its Free plan); past it the answer is a 413 that says so. Video uploads have their own budget: 20 an hour per key.

GET ?kind=video lists this workspace's videos only; plain GET lists images, and every row carries kind. DELETE /api/v1/media/:id removes a video with its poster. A funnel may only play its own workspace's videos (or YouTube and Vimeo links); anything else is a 422, "video does not belong to this workspace".

Fonts

A brand font that is not on Google Fonts goes through the same POST /api/v1/media, into the workspace font library the Design tab shows. A body is treated as a font when it has "kind": "font", a font content type, or a .woff2, .woff, .ttf or .otf file name or URL:

{ "kind": "font", "url": "https://example.com/ControlCursive-Bold.otf" }
{ "kind": "font", "data": "d09GMgABAAAA...", "filename": "ControlCursive-Bold.woff2",
  "family": "Control Cursive", "weight": 700, "style": "normal" }    // the last three optional
{
  "url": "https://.../fonts/<org>/...-Control-Cursive-700.woff2",
  "family_suggestion": "Control Cursive",
  "converted_from": "opentype",
  "font": { "id": "...", "family": "Control Cursive", "weight": 700, "style": "normal", "format": "woff2", "bytes": 41210 },
  "custom_entry": { "family": "Control Cursive", "url": "https://...", "weight": 700, "style": "normal", "format": "woff2" }
}

woff2, woff, ttf or otf, up to 5MB in. The file's signature decides, not its name or content type. A ttf or otf is stored as woff2, and what is stored must be 2MB or less. Family, weight and style come from the file unless given. The same face uploaded twice answers 200 with the first upload and "existing": true; a new one answers 201. Only upload fonts the client has a web licence for: the file is public. Add custom_entry to theme.fonts.custom with an id to use it; see the Custom fonts section of the funnel document guide.

GET /api/v1/media?kind=font lists the workspace's fonts. Fonts are per workspace: files are stored under the workspace's own folder, only that workspace can list them, and saving or publishing a document whose theme.fonts.custom[].url is not one of them (another workspace's upload, or an external host) answers 422 with "font does not belong to this workspace" and the path of the offending entry.

What the document controls

Everything. brand (name, logo, position, logoLink for where a tap on the logo goes, 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. The URL has to be a Calendly, cal.com or GHL booking calendar (a GHL white-label domain is fine at its /widget/booking/<calendar id> link); publish refuses any other page.

Conversions API credentials are not in the document, since the document is public. Set them with PUT /api/v1/funnels/:id/tracking/:platform (see Server-side tracking) or in the app, Connect tab.

Webhook payloads

Lead (new_lead) and completion (funnel_completed) webhooks POST one flat JSON object. Every key below is always present (empty string, false or 0 when unknown), so a CRM can map a field before the first lead that fills it. Map what you need and ignore the rest. schema_version (now 2) only changes when a key's meaning does; new keys can appear at any time.

Group Keys
Answers every form and question field by its own name, <field>_label for choice answers and answers_summary; on the lead also first_name / last_name split from a full name and answers as one nested object
Lead event, lead_id, created_at, score, score_max, qualified, result_page, result_heading, tally_leader (lead); outcome (completion)
Funnel funnel (slug), funnel_id, funnel_name, campaign, variant_id, test
Source traffic_source ("Google Ads", "Meta Ads", "Direct"…), utm_source, utm_medium, utm_campaign, utm_content, utm_term, campaign_id, adset_id, ad_id, placement, site_source_name, landing_page (no query), landing_url (full), referrer_url
Click ids fbclid, gclid, gbraid, wbraid, dclid, msclkid, ttclid, twclid, li_fat_id, oppref, raw as they arrived (from the visit, else the landing URL); placeholders like {gclid} are dropped
Meta fbc, fbp, meta_event_id (lead), visitor_id (also Meta's external_id)
Visitor visitor_ip, visitor_user_agent, visitor_language, visitor_device (mobile, tablet, desktop), visitor_browser, visitor_os
Location geo_country, geo_region, geo_region_code, geo_city, geo_postal_code, geo_timezone, from the visitor's IP at our edge
Consent (lead) consent_given, consent_field, consent_text, consent_at, consent_ip, consent_url: the box the visitor ticked, its exact wording, when, from where
Ad consent ad_consent: granted, denied, not_required (no opt-in banner) or unknown

When ad_consent is denied (the visitor declined the funnel's cookie banner) the ad identifiers are sent empty: every click id, fbc, fbp, visitor_id, meta_event_id, and the click ids in landing_url. Campaign tags (utm_*, ad ids) still arrive, since they describe the ad rather than the person.

Never sent: latitude and longitude (precise geolocation is sensitive personal information under California law and its copies), device fingerprints, answers from a form that was not submitted, and anything about the human check. The visitor's IP and browser are read from the request when the webhook is sent and are not stored with the lead, except inside the consent record.

Webhook signatures

Lead (newLead) and completion (funnelCompleted) webhooks carry an X-Unfunnel-Signature header, so the receiver can tell a real lead from somebody POSTing to its URL:

X-Unfunnel-Signature: t=1760000000,v1=5f2b...

v1 is the hex HMAC-SHA256 of <t>.<raw request body>, keyed with the workspace's secret from GET /api/v1/webhooks/secret. Recompute it, compare in constant time, and refuse a t more than five minutes old. One secret per workspace, the same for all its funnels.

const [t, v1] = header.split(",").map((p) => p.split("=")[1]);
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const ok = crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected)) && Date.now() / 1000 - Number(t) < 300;

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.","website":"https://harborline.example","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"

Launch on the client's own domain, A to Z:

curl $BASE/api/v1/workspace -H "Authorization: Bearer $KEY"                    # plan and limits
curl -X POST $BASE/api/v1/brand -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" -d '{"url":"https://brand.com"}'          # logo, colours, fonts
curl -X POST $BASE/api/v1/media -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" -d '{"url":"https://brand.com/og.png"}'   # social preview image
curl -X POST $BASE/api/v1/funnels -H "Authorization: Bearer $KEY" -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" -d '{"prompt":"...","website":"https://brand.com"}'
# PATCH the draft: meta.image, meta.favicon, settings.metaPixelId, settings.webhooks; check /preview
curl -X PUT $BASE/api/v1/funnels/$ID/tracking/meta -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" -d '{"access_token":"EAAB..."}'
curl -X POST $BASE/api/v1/funnels/$ID/publish -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" -d '{"slug":"brand-quiz"}'
curl -X POST $BASE/api/v1/domains -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" -d '{"hostname":"quiz.brand.com","funnel_id":"'$ID'"}'
# the client creates the CNAME it returns, then:
curl -X POST $BASE/api/v1/domains/quiz.brand.com/verify -H "Authorization: Bearer $KEY"
curl -X POST $BASE/api/v1/funnels/$ID/test-link -H "Authorization: Bearer $KEY"   # walk it as a test
curl "$BASE/api/v1/funnels/$ID/tracking" -H "Authorization: Bearer $KEY"          # server events landing?

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://unfunnel.io/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.

A connector's token works here, on /api/v1 and /api/mcp, and nowhere else. Sent straight to the Supabase project (/rest/v1, Storage or an RPC) it reads and writes nothing, so it can only ever reach the one workspace you picked. Disconnecting also ends the app's sessions at Supabase, which deletes its refresh tokens. An access token it already holds stays valid at Supabase until it expires (at most an hour), but nothing accepts it, and connecting the app again does not revive it: only tokens issued after the latest approval are honoured.

Or use a key

Claude Code:

claude mcp add --transport http unfunnel https://unfunnel.io/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://unfunnel.io/api/mcp",
           "--header", "Authorization: Bearer tl_live_..."] } } }

Tools, by job:

  • Workspace: get_workspace, get_brand_kit, brand_from_url, list_templates, funnel_document_guide, check_slug
  • Funnels: list_funnels, get_funnel, create_funnel, update_funnel, preview_funnel, delete_funnel
  • Going live: publish_funnel, unpublish_funnel, list_versions, get_version, restore_version, test_link
  • Domains: list_domains, connect_domain, verify_domain, remove_domain
  • Tracking: get_tracking, set_tracking_key, remove_tracking_key, connect_google_ads, set_up_google_ads, get_webhook_secret
  • Media: list_images, upload_image, list_videos, upload_video, list_fonts, upload_font, get_upload_url, finish_upload, delete_media
  • Results: get_leads, list_leads, update_lead, delete_lead, get_metrics, clear_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. 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. A key the format does not have is refused too, with the keys that are valid there, rather than silently dropped. A few obvious ones are renamed and reported instead: name on a quote becomes attribution, author on a testimonial becomes name, question and answer on a faq item become q and a.

Brand first

A funnel's look is not written by hand and not chosen by a model. It is read from the brand's own website:

  • POST /api/v1/brand with { "url": "https://brand.com" } (the MCP tool brand_from_url) opens the site in a real browser, takes its logo (stored in the workspace as a transparent PNG), its page, text and button colours, the colour it uses most, and its heading and body faces matched to Google Fonts, and keeps that as the workspace's brand kit. The reply carries the kit and the theme built from it.
  • create_funnel with a prompt needs a website (or brand_colors with at least an accent). The draft's theme, fonts and logo come from the kit and nothing the model writes can change them.
  • Writing a document yourself, paste the kit's theme as it is and set brand.logoUrl to the kit's logo and brand.website to its site. A theme that drifts from the kit's colours is a blocking fix.

Three built-in templates show the patterns in full. Create one with POST /api/v1/funnels and { "template": "b2b-free-offer" }, { "template": "wellness-quiz" } or { "template": "saas-fit" }, then GET it back to read the document.

  • b2b-free-offer: the landing page recipe below, block for block. A free offer in the hero, a product screenshot, proof, a symptom section with a second button, how it works, testimonials, who it is for, a founder quote, an FAQ and a closing band, then a role question first.

  • wellness-quiz: the long-form quiz 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>
            "adHooks": ["..."] },                       // optional: the ads' hooks, never shown on the page
  "theme": { ... },
  "settings": { ... },
  "pages": [ ... ]
}

brand

{
  "name": "Halewell",              // the brand's name, used in titles and alt text
  "logoUrl": "https://...",        // required: a funnel without its logo is refused at publish
  "website": "https://halewell.com", // the site the brand kit was read from
  "logoWithName": true,            // the file is an icon: set the name beside it
  "logoPosition": "center",        // center | left. Centre with a centred page
  "logoSize": "md",                // sm | md | lg; lg grows again on a wide screen
  "logoLink": "https://halewell.com", // optional: tapping the logo opens this page (http/https)
  "footer": "Halewell Health, Austin, TX.",   // small print, end pages
  "legal": "Not affiliated with..."           // finer print on every end page, not-a-fit included
}

logoWithName is for a logo that is a mark with no words in it, a badge or an app icon. The brand name is set beside it in the display face and takes the theme's ink colour, so it stays readable on a light page and a dark one. A full wordmark file leaves it out.

logoLink makes the header logo a link, usually to the brand's own site. Leave it out unless the brand asks for it: a visitor who taps away mid-funnel is a lead lost. It works on the live page only, never on the edit canvas or a preview.

theme

Eight colors, a radius scale, an option style, a button style, and two Google Fonts families. Take it from the brand kit (see Brand first) rather than writing it: the kit's theme is already contrast-checked.

{
  "ground": "#F7F4ED",     // page background
  "surface": "#FFFFFF",    // cards and inputs
  "surfaceHi": "#E9E4D8",  // progress track
  "ink": "#22271F",        // body text
  "inkMuted": "#6B7263",   // secondary text
  "line": "#E2DCCC",       // input borders
  "accent": "#33707E",     // buttons, progress fill, active states
  "accentInk": "#FFFFFF",  // text on top of accent
  "accentAlt": "#C7E14A",  // optional second colour, decoration only
  "radius": "xl",                          // sm | md | lg | xl
  "style": "soft",                         // soft | app | classic. See below
  "optionStyle": "card",                   // card | filled (solid tiles)
  "button": { "shape": "rounded", "caps": false },
  "fonts": { "display": "Lora", "body": "Inter", "displayWeight": 600 }
}

fonts.display and fonts.body take any family Google Fonts serves, spelled as fonts.google.com spells it, or a family uploaded to the workspace (see Custom fonts). Anything else loads nothing and the page falls back to a system face; the design review flags it as type.unknown_font with the closest free face. A theme built from a website may also carry fonts.brandFaces ({ "display": "Gotham" }): the site's own faces that Google has no copy of. It is never loaded; the Design tab uses it to offer the upload to whoever holds the licence.

style: "soft" is the look every AI draft and every brand kit theme uses: cards are tinted surfaces with no outline, icons are plain line icons with no disc behind them, eyebrows and badges stay in sentence case, and section bands run edge to edge. Leave it out and a document renders the classic way.

style: "app" is the mobile quiz app pattern every built-in template wears (the reasoning is in docs/QUIZ-DESIGN-PATTERNS.md). It changes the quiz pages, not the landing page:

  • A question page (layout center, after the first page) has a top bar with back and the progress on one line and no logo. The question hangs from the top of the screen instead of floating in the middle.
  • Answers are tinted cards. The picked one takes the accent and a check pops in; optionStyle: "filled" is ignored here, so a column of answers never reads as a column of buttons.
  • The page's button (a cta, a form's submit, a multi-select's Continue) sinks to the bottom of the screen and sticks there while the page scrolls under it.
  • A loader page (autoAdvance) shows a ring counting to 100 instead of a spinner, and a list block on it ticks its rows off one by one across the wait. Give it three or four short rows ("Reading your answers", "Matching your goal").
  • An outcome page opens with a small mark and lets its headline reveal the result. Put the payoff there: the answer echoed back ({{field}}), a chart, a features list of what they get.

Between questions, an insight page gives something back: an eyebrow, a heading, then a chart or a testimonial with rating, and a cta labelled Continue. One every two or three questions keeps a quiz from feeling like a form.

The theme can carry the brand's gradient, defined once and referenced by bands and buttons rather than repeated:

"gradient": { "stops": ["#062141", "#13b3cf", "#23c2b7", "#43e285"], "angle": 90, "ink": "#062141",
              "rules": true, "progress": true }

Two to four stops, an angle in degrees, and the ink that reads on top. Without one, the accent into the second accent stands in. rules draws a short gradient line under every headline that follows an eyebrow; progress paints the funnel's progress bar with it.

"backdrop": "glow" lights the page's ground from two corners with the gradient's bright colours (the top right, and the left a phone screen down), faint enough that copy still reads on the ground. It is for a light page that feels like a blank sheet of paper; pair it with a ground a shade off white rather than pure white. Absent (or "flat") is the ground colour alone.

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

Emphasis is optional and used at most once a page: wrap the two or three words a headline turns on in [[double brackets]], or leave every heading plain. More than one on a page is usually one too many. The words take the treatment defined once on the theme:

"emphasis": {
  "font": "script",        // "display", "body", or an extra or custom 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.

Custom fonts

For a brand font that is not on Google Fonts, and only one the client holds a web licence for: the file is publicly downloadable from any funnel that uses it.

  1. Upload the file with upload_font (MCP) or POST /api/v1/media with "kind": "font". woff2, woff, ttf or otf, up to 5MB; a ttf or otf is converted to woff2, and what is stored must come out at 2MB or less. It answers with url, family_suggestion (read from the file) and custom_entry, which is ready to paste into fonts.custom.
  2. Add it to fonts.custom with an id, which makes it a font slot.
  3. Point typography roles or emphasis.font at that id.
"fonts": {
  "display": "Inter", "body": "Inter",
  "custom": [{ "id": "brand", "family": "Control Cursive", "url": "https://.../ControlCursive-Bold.woff2", "weight": 700 }]
},
"emphasis": { "font": "brand", "color": "accent" }

Each entry in fonts.custom is one file:

field
id optional, lowercase letters, numbers and dashes, up to 24. Unique across fonts.extra and fonts.custom, and not display or body
family the CSS family name. Letters, numbers, spaces, dots, underscores and dashes, up to 60
url the URL the upload returned. It must be a font uploaded to this workspace: anything else, another workspace's upload or an external host, is refused with a 422, "font does not belong to this workspace"
weight this file's weight, default 400
style normal (default) or italic
format woff2, woff, truetype or opentype. Optional, read from the URL's extension

The page gets an @font-face rule (with font-display: swap) and a preload for each file. A slot id resolves to its family, then the funnel's body font, then the system sans serif, so text stays readable while the file loads. A family with several files (Regular, Bold, Italic) lists each one with the same family; only the one the roles point at needs an id, and the browser picks the others by weight and style. For an italic face on emphasis, set emphasis.italic too.

display and body take family names, not slot ids. A custom family works there as well: "display": "Control Cursive" with that family in fonts.custom is loaded from the upload, never from Google.

Fonts belong to the workspace that uploaded them. list_fonts (MCP) or GET /api/v1/media?kind=font shows this workspace's library and nothing else, and a document copied into another workspace has to have its fonts uploaded there again.

Uploaded fonts are a Growth plan feature. On a plan without them, a live page drops the uploaded faces and every role falls back as above.

settings

{
  "progress": "bottom",         // bottom (default) | top | none. Question pages only, never the landing page
  "resumeOnReturn": true,       // sessionStorage resume after refresh
  "campaignLabel": "halewell_metabolism_quiz",   // stamped on payloads
  "metaPixelId": "1234567890",  // this funnel's browser pixel
  "openaiPixelId": "Cwnx7LYSrvdcZYZdP1Zr3c",  // ChatGPT Ads pixel: page viewed, lead created, appointment scheduled
  "googleAdsId": "AW-123456789",                         // Google tag for Google Ads
  "googleConversions": { "submit_lead_form": "AbCdEfGh123" }, // conversion action labels, by category
  "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, the Meta identifiers (fbclid is the raw click id, fbc and fbp the browser cookies), meta_event_id so the CRM row can be joined to the Meta Lead event, and the outcome the visitor is about to see: result_page (the id of the page after the form, skipping auto-advance interstitials), result_heading (that page's first heading, e.g. "The Clarity Gap") and tally_leader (the tally a tallyMax rule picked). A CRM message can quote the result instead of decoding answer values. metaEvents maps funnel moments (landing, step, lead, qualified, disqualified) to Meta standard events, or off.

Google Ads works differently from Meta and ChatGPT: every conversion is a conversion action in the advertiser's own account, with its own label. So settings.events names a Google conversion category for each moment (submit_lead_form, book_appointment, qualified_lead, contact, request_quote, sign_up, page_view, outbound_click, or off; defaults: lead submit_lead_form, booked book_appointment, the rest off), and googleConversions holds the label of the action each category sends to. Do not write googleAdsId or googleConversions by hand when the account can be connected: Connect Google Ads in the app, or connect_google_ads then set_up_google_ads over MCP, creates the actions and fills both in. A category with no label is not sent from the browser.

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": "center",        // 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" },
  "hero": "auto",                       // top pages on a wide screen: auto (split) | stacked
  "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 familiar "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 every page: a landing page that hangs off one edge beside centred questions reads as two products.
  • 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. Most pages want none: a real photo of the brand does that job better.
  • Validation rejects dead ends: every non-terminal page needs next plus a block that can trigger navigation, and every route target must exist.

The landing page

Write the landing page as a plan, not as blocks: what it says, and Unfunnel lays it out. Pass it as landing to POST /api/v1/funnels (with a doc of quiz pages or a template) or to PATCH /api/v1/funnels/:id; over MCP it is the landing input of create_funnel and update_funnel. An AI draft writes one too.

{
  "eyebrow": "For owners of service businesses",          // optional
  "headline": "Know your real profit every Monday morning",
  "subhead": "Get a free books health check: we review your last 3 months and show you what your numbers are hiding.",
  "cta": { "label": "Get my free books check →", "note": "Free · No commitment" },
  "media": { "kind": "video", "url": "https://...", "alt": "" },   // optional, only real media
  "proof": { "logosHeading": "Trusted by", "logos": [{ "name": "..." }], "stats": [{ "value": "$600", "label": "value, free", "icon": "dollar-sign" }] },
  "sections": [
    { "kind": "problem", "heading": "Sound familiar?", "items": [{ "title": "...", "text": "..." }] },
    { "kind": "steps", "heading": "How it works", "items": [{ "title": "...", "text": "..." }] },
    { "kind": "benefits", "heading": "...", "items": [{ "icon": "clock", "title": "...", "text": "..." }] },
    { "kind": "testimonials", "items": [{ "quote": "...", "name": "..." }] },
    { "kind": "audience", "heading": "Built for", "items": ["...", "..."] },
    { "kind": "faq", "items": [{ "q": "...", "a": "..." }] }
  ],
  "closing": { "heading": "See what your numbers are hiding", "subhead": "Two minutes to start." }
}

How it is laid out, the same way every time, in the brand's colours and type:

  • Hero: eyebrow, a big centred headline, the offer, one full-width button and its note, then the picture. What sits behind it is optional: "backdrop": "wash" (a soft gradient of the brand colour), "tint" (a flat tint) or "plain" (the page). Left out, pages vary. With no media, the picture is made for you: the funnel's first question in a phone on a soft brand tint.
  • Proof straight under it: the logo strip, then up to three big numbers.
  • Sections each get a centred heading and one card pattern (symptom rows, numbered steps, icon cards, quotes, a checklist, questions). On a page with three or more, the middle one sits on a dark full-bleed band with the button again under it.
  • Closing: the heading and the same button on a dark band. "closing": { "style": "glow" } lights the band with the brand colours, "plain" leaves it on the page.

Two to four sections tell a story best. Leave a kind out rather than pad it: real quotes, names and numbers only. The quiz pages that follow are written as ordinary pages (below).

The quiz. The first question is an easy identity question (role, business type, team size) with at most four answers, each with an icon. Ask about problems second. Later questions have at most six answers, each under 40 characters.

Ad hooks. Put the hooks of the ads that send traffic to the page in meta.adHooks, word for word, and echo one of them in the hero headline or subtext: a visitor who tapped one promise and landed on another leaves. A hero that shares no words with any hook comes back as a fix.

Fixes

Every create and update through the API or MCP replies with any fixes the page still needs: what to change, where (pages.0.blocks.3), and whether publishing waits for it. Make them and save again. Publishing is refused while a blocking fix is open, unless force: true is passed, which is logged.

Blocking fixes, the ones publishing waits for, are the few things that make a page look broken: no brand.logoUrl, colours that are not the brand kit's, a social share image used as a photo, an offer hidden at the bottom, a hero button that says nothing ("Learn more"), and long dashes in copy. How long a line is, and which face it is set in, are left to the layout.

On colour and type: one heading face, one accent (the brand's button colour), tints of the brand's second colour for cards and bands, and a white or off-white ground when that is the brand's. Cards are tinted, never outlined. All of it comes from the brand kit.

Blocks

Presentational: eyebrow (optional emoji before the text), heading (sizes md, lg, xl), subtext (size md or lg for a hero paragraph), spacer (xs to xl), section (a background band, below), badge, stats, image, video (an uploaded file or a YouTube/Vimeo link, below), list (checkmarked), testimonial (quote and name, optional rating 1 to 5 for stars over the quote), chart (below), quote (founder pull quote: text and attribution, never name), faq (below), features (icon cards, rows or numbered steps), logos (customer or press strip), spacer, divider, legal.

A faq block is questions and answers as an accordion: one answer open at a time, every question a keyboard-reachable button. Write the objections a visitor would otherwise leave to go and ask, three to six of them, each answer two sentences at most.

{ "type": "faq", "items": [
  { "q": "Is it really free?", "a": "Yes. One team, up to 10 people. No commitment." },
  { "q": "How long does it take?", "a": "About 20 minutes each, online." } ] }

A chart block is the projection curve a quiz shows between questions: a smooth line from where the visitor is now to where they will be, nothing plotted. trend is rise (savings, leads, energy) or fall (weight, hours, cost). from and to label the two ends and may use {{field}}; marker is a short tag on the end point; caption is one muted line under it.

{ "type": "chart", "trend": "fall", "from": "Today", "to": "Week 8",
  "marker": "{{goal_weight_kg}} kg", "caption": "Most of the change happens in the first eight weeks." }

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://..." }] }

A section block opens a background band: every block after it, up to the next section or the end of the page, sits on the chosen colour, inset in the column with the theme's radius. The colours are palette roles, so the band takes the theme's own ink, surface, hairline and button colours remapped for contrast, and a block never knows where it sits. ground ends a band.

{ "type": "section", "tone": "ink" },        // the dark panel mid-page
{ "type": "heading", "text": "Why work with us?" },
{ "type": "features", "items": [ ... ] },
{ "type": "cta", "label": "Start" },
{ "type": "section", "tone": "ground" }      // back on the page

Tones: ink (dark), accent, surface, surfaceHi (a tint), ground.

Icons

Answers, feature items and stat tiles take an icon: a line icon by name, drawn in the accent colour on a soft disc of it, so it reads as part of the brand where an emoji would not. The names are the closed set in src/lib/funnel/icons.ts (store, fuel, shield-check, trending-up, calendar, ...); an unknown name fails validation and the error lists the valid ones. When an item has both, the icon wins.

{ "type": "features", "layout": "list", "items": [
  { "icon": "dollar-sign", "title": "Estimated charging revenue", "text": "" },
  { "icon": "calendar", "title": "Projected payback period", "text": "" }
]},
{ "type": "features", "layout": "steps", "items": [
  { "title": "See if your site qualifies", "text": "Take the 60 second qualifier." }
]},
{ "type": "stats", "items": [{ "icon": "clock", "value": "10+", "label": "Sessions a day" }] }

features.layout: cards (the default tile grid), list (stacked rows, icon left, text optional) or steps (rows numbered in accent circles, the "how it works" pattern).

A band also takes a fill: flat (the tone), gradient (painted with the theme's gradient, text in the gradient's ink) or glow (the tone, lit from two corners by the gradient's colours). A cta with "style": "gradient" wears the gradient too.

{ "type": "section", "tone": "ink", "fill": "glow" },
{ "type": "heading", "text": "Ready to see what your property could earn?" },
{ "type": "cta", "label": "Get a site assessment", "style": "gradient" }

Text in eyebrow, heading and subtext supports {{field}} interpolation from collected answers: "{{first_name}}, your result is on its way."

video

{ "type": "video",
  "url": "https://.../storage/v1/object/public/videos/<workspace>/...-intro.mp4",
  "poster": "https://.../...-intro-poster.jpg",   // optional: the picture before play
  "aspect": "16:9",                               // optional: 16:9 (default) | 9:16 | 1:1
  "autoplay": true }                              // optional: start muted once on screen (default false)

url is either a video uploaded to this workspace or a YouTube or Vimeo link, which is framed instead. Upload with upload_video (MCP) or POST /api/v1/media: MP4 (H.264) or WebM, up to 100MB. The answer carries the url to use and a poster_url for poster, plus duration_seconds, width and height; a clip taller than it is wide wants "aspect": "9:16". list_videos shows the workspace's library.

The player shows controls, plays inline on an iPhone, loads only the first frame and the length until someone presses play, streams as it plays, and never starts by itself unless autoplay is on, so nobody arriving from an ad is met with sound.

"autoplay": true starts the video muted once at least half of it is on screen, and pauses it when it scrolls away, so it never plays under the fold. While it plays muted, a "Tap for sound" button in the theme's accent colour sits bottom-left; tapping it turns the sound on and starts again from 0:00, and it goes away once the sound is on, however the viewer turned it on. It does not loop. Visitors whose device asks for reduced motion get the ordinary poster and play button instead. On a YouTube or Vimeo link it adds the host's own muted-autoplay parameters. Browsers only allow autoplay without sound, so sound always waits for a tap. Portrait and square frames are capped in width and centred, so a tall clip never outgrows a desktop screen.

Like fonts, videos belong to the workspace that uploaded them: a video file from another workspace, or from any other host, is refused with a 422, "video does not belong to this workspace". A poster may be any public image, but one on Unfunnel storage has to be this workspace's too. A video block with an empty url saves, and shows "No video chosen yet" until one is picked.

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",
      "icon": "store",                   // a line icon in the accent, or "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
  "leadEvent": false,        // optional; false: saves + webhooks, no Lead event
  "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. "leadEvent": false on a lead form saves the lead and fires the webhooks but sends no Lead event to any ad platform: use it for a details form on a not-a-fit path, so an ad set optimising for Lead does not learn from the visitors the funnel screens out.

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

"passParams": true on an https link adds the visit's ad parameters to it when tapped: utm_*, click ids (fbclid, gclid and the like) and ad ids, from the address the visitor landed on, or from the ad that first brought them if they came back direct. Never answers or anything else in the URL, and values the link already sets stay as they are. The site the button opens then knows which ad sent the visitor.

{ "type": "cta", "label": "Visit our website", "href": "https://example.com", "passParams": true }

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.

Publishing checks the address. Calendly (calendly.com), cal.com (cal.com) and GHL's own hosts (leadconnectorhq.com, msgsndr.com) are accepted for any provider. A GHL calendar on the agency's white-label domain is accepted with "provider": "ghl" when it is the calendar's widget link, https://<domain>/widget/booking/<calendar id> (or /widget/bookings/<slug>, /widget/group/<id>). Any other page is refused at publish and not drawn on a live page. The frame is sandboxed: the calendar runs its scripts, forms and popups but cannot navigate the funnel page.

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. Read the brand first: POST /api/v1/brand (brand_from_url). Its reply is the logo, the theme and the fonts the funnel uses.

  2. The quickest route is create_funnel with a prompt and the website: the copy is drafted, the brand applied, a hero picture made if the brand has none, and the result checked visually and revised before it comes back. Writing it yourself, pass the landing page as a landing plan (above) with a doc of quiz pages, put the kit's theme on the doc and set brand.logoUrl and brand.website. 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. A file on your own machine goes up through an upload link (POST /api/v1/media/uploads, the MCP tool get_upload_url) rather than as base64.

    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. The response lists any fixes the page still needs; make them with PATCH, which replies with whatever is still open.

  5. Preview it (GET /api/v1/funnels/:id/preview, or preview_funnel) to see it, and check nothing is left to fix before publishing.

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