# 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](https://unfunnel.io/docs/funnel-document.md)),
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](#connect-claude-mcp). 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.

```jsonc
{ "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
```

```json
{ "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:

```json
{ "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:

```json
"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.

```json
"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`.)

```jsonc
{ "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.

```jsonc
{
  "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.

```jsonc
{ "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:

```jsonc
{
  "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.

```jsonc
{
  "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: 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.

```jsonc
{ "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.

```jsonc
{ "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:

```jsonc
{ "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.

```jsonc
{ "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:

```jsonc
{ "url": "https://example.com/logo.png" }                      // copy something already online
{ "data": "iVBORw0KGgo...", "content_type": "image/png" }      // base64 bytes, or a data: URI in data
```

```jsonc
{ "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:

```jsonc
{ "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:

```jsonc
{ "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
```

```jsonc
{
  "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:

```json
{ "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.

```js
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:

```bash
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:

```bash
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:

```bash
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:

```bash
curl "$BASE/api/v1/funnels/$ID/leads?limit=200" -H "Authorization: Bearer $KEY"
```

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

```bash
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:

```bash
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:

```json
{ "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.
