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, withpublish_error,fixesand 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"] }
Links
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.
Test links
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: trueit goes everywhere a real lead goes, carryingtest: true(the field is always present,falsefor 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
metaTestCodeis 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.