# Funal Headless API: AI Forms and Scheduling

This guide is for developers building their own UI on top of Funal's
generative intake forms and scheduling. It documents the HTTP surface as it
exists in `funal-api` today, so you can render a form, run the AI interview,
submit a lead, show availability, and book, all from your own frontend.

- **Base URL:** `https://funal-api.alex-boquis.workers.dev` (also served at
  `https://api.funal.ai` once DNS is wired). All examples use `$BASE`.
- **Format:** JSON in, JSON out. `Content-Type: application/json`.
- **Tracing:** every response carries an `X-Trace-Id` header. Quote it when you
  report a problem.

## 1. Two surfaces, two trust models

| Surface | Path prefix | Credential | Use it for |
|---|---|---|---|
| **Public runtime** | `/public/forms/...`, `/public/booking/...` | none. The org id is in the path. | Everything a visitor does: load a form, answer questions, submit, see slots, book, reschedule, cancel. |
| **Control plane** | `/scheduling/...`, `/atoms/...`, `/mcp` | your Funal API key | Everything an operator does: author forms, list providers and pools, book on behalf of a client, cancel or reschedule in bulk. |

The public runtime is deliberately credential-less so you can host the form
or booking page on your own domain. CORS on `/public/*` echoes any origin.
Nothing sensitive is reachable there: the org id only selects which org's
public forms and public providers are visible, and every write runs as a
restricted system actor.

The control plane requires an API key and is CORS-restricted to Funal's own
origins. Call it from your server, not from a browser.

### Authenticating with an API key

Create a key in Funal: avatar (bottom-left) → **Manage account** → **API
keys** → **Add new key**. The key acts as you, in your organization.

```
Authorization: Bearer <YOUR_FUNAL_API_KEY>
X-Funal-Org-Id: <org id>        # only required if you belong to more than one org
```

If you belong to exactly one org, the org resolves automatically. If you
belong to several and omit the header, you get `400` with the list of your
orgs.

Where to find the ids you will need:

- **Org id** (`org_...`): from the `400` response above, or from the URL of
  your Funal workspace.
- **Form id**: `form_list` over MCP, or `GET $BASE/atoms/intake_config`.
- **Provider id** (`user_...`) and **pool slug**: `GET $BASE/public/booking/:orgId/config`
  (providers only) or `scheduling_targets` over MCP (providers and pools).

---

## 2. AI forms (generative intake)

### How a Funal form works

A Funal form is not a list of fields. It is a **configured interview**:

1. The visitor sees an opener question and, optionally, a contact card.
2. Each subsequent question is generated by a model from the answers so far,
   steered by the form's `next_question_instruction`. The server may also
   force a few must-ask "closer" questions before it lets the interview end.
3. On submit, a second model pass extracts a structured qualification object
   from the transcript, decides `qualified`, and, if the form has a booking
   target, returns a signed **offer token** that lets the visitor book.

**The client owns the interview state.** There is no server session. You
send the running `answers` map and `turn` counter with every call and the
server returns the next question. Nothing is written until submit, except an
optional draft you can enable by sending an `anonymous_id`.

The form definition lives in an `intake_config` record. One form id can have
several **variants**; exactly one should be `active`. Public calls use the
active variant unless you pass `variant` explicitly.

### 2.1 `GET /public/forms/:orgId/:formId`

Loads the form's copy, contact fields, mode, and booking target. Call it once
when the page loads.

Query: `lt` (optional personal invite token, see 2.6).

```json
{
  "data": {
    "id": "d291fc82",
    "name": "ADHD coaching intake",
    "schema": null,
    "mode": "interview",
    "booking": { "poolSlug": "admissions", "providerId": null, "durationMinutes": 30, "optional": false },
    "copy": {
      "badge_label": "…", "intro_title": "…", "intro_body": "…", "cta_label": "…",
      "opener_question": "What's going on right now?",
      "opener_options": ["Focus", "School", "Motivation"],
      "processing_title": "…", "processing_kicker": "…",
      "choice_eyebrow": "…", "choice_title": "…", "choice_body": "…",
      "choice_schedule_label": "…", "choice_defer_label": "…", "choice_footer": "…",
      "schedule_title": "…", "schedule_kicker": "…",
      "nurture_title": "…", "nurture_body": "…",
      "booked_title": "…", "booked_body": "…",
      "doc_request_label": "…"
    },
    "contactFields": [
      { "key": "first_name", "label": "First name", "type": "text", "required": true },
      { "key": "email", "label": "Email", "type": "email", "required": true }
    ],
    "invite": null,
    "interview": { "maxTurns": 4, "closers": 2, "maxQuestions": 6 },
    "creates": { "record": "account", "matchOn": "email" }
  }
}
```

Notes:

- `creates` is what a submit will write: `lead` or `account` (an account
  plus the visitor as its person). Returning visitors are recognized by
  `matchOn` (email). The form owner sets it in Funal (`intake_config.creates`,
  and `account_name` such as `{{last_name}} family`). See 2.3.

- `interview` tells you how long the interview can run, for a progress bar.
  `maxTurns` is the number of story questions including the opener. `closers`
  is how many must-ask questions may be added at the end. `maxQuestions` is
  the upper bound. Closers are only asked when the story left them unknown,
  so show progress as "question 3 of about 6", not an exact count. It is
  `null` for `collect` forms.
- `mode` is `interview` (AI questions) or `collect` (document collection,
  no AI; `/next` immediately returns `done`).
- `schema` is a legacy static field schema. It is usually `null` for forms
  authored over MCP. Ignore it unless you are rendering an old form.
- `booking` is `null` when the form has no booking target. `optional: true`
  means show a "book" and a "not now" choice after submit.
- Copy fields may contain `{{first_name}}` and `{{org_name}}`. Substitute
  them yourself.
- `invite` is set only when a valid `lt` was passed and carries the invited
  person's name and email so you can prefill.
- `404 form_not_found` if neither a form nor an active config exists.

**Turn 1 is yours.** The server never serves the opener. Render
`copy.opener_question` (with `opener_options` as chips plus a free-text
"something else") as the first question, store the answer under a key of
your choice (the Funal UI uses `situation`), then call `/next` with
`turn: 2`.

### 2.2 `POST /public/forms/:orgId/:formId/next`

Asks for the next question.

Query: `variant` (optional).

Request:

```json
{
  "turn": 2,
  "answers": { "situation": "My son can't stay on top of homework" },
  "known": {},
  "anonymous_id": "a1b2c3",
  "lt": "…"
}
```

- `turn`: 1-based counter of the question you are about to ask. Increment
  it every time you receive a question.
- `answers`: every answer so far, keyed by the `key` the server gave you.
  Keys are capped at 60 chars, values at 800.
- `known`: pass back the `known` object from the previous response
  unchanged. It is the model's running read of the signals it is tracking.
- `anonymous_id` (optional): a stable id for this visitor. If present, the
  server upserts an `intake_draft` record every turn, so abandoned
  interviews are still visible in Funal. Reuse the same id on submit.
- `lt` (optional): personal invite token.

Response, when there is another question:

```json
{
  "question": "How long has that been going on?",
  "field_type": "select",
  "options": ["A few weeks", "This semester", "Years"],
  "key": "duration",
  "done": false,
  "known": { "intent": "exploring" },
  "slot": "affordability"
}
```

- `field_type` is one of `text`, `textarea`, `select`, `multi_select`.
- `options` is present for select types, at most 4 entries. The server strips
  any "other / something else" option; render that affordance yourself and
  let the visitor type.
- `key` is the answer key to use in `answers` for this question.
- `slot` is present only when the question is a forced closer for a required
  signal. It is informational.

Response, when the interview is over:

```json
{ "done": true, "known": { "…": "…" } }
```

The server ends the interview when the model says it is done and every
required signal is known, or when the turn budget (`max_turns` plus the
number of closers) is spent. On a model error it serves any remaining closer
or ends the interview. Your loop should treat `done: true` as the only exit.

#### Going back and editing

State is yours, so a back button needs no endpoint. Drop the later answers
from your `answers` map, set `turn` back, and call `/next` again.

### 2.2a `GET /public/forms/:orgId/:formId/draft`

Resumes an unfinished interview. If you sent `anonymous_id` on `/next`, the
server kept the running answers, and this reads them back.

Query: `anonymous_id` (required).

```json
{ "data": { "answers": { "situation": "…", "duration": "…" }, "turn": 3, "variant": "b" } }
```

- Continue by calling `/next` with those `answers`, that `turn`, and
  `known: {}`. If `variant` is present, pass it as `?variant=`.
- `{ "data": null }` means there is nothing to resume: no draft, or the
  visitor already submitted.
- The `anonymous_id` is the only credential, so it must be a random id your
  app generates and stores on the device (for example
  `crypto.randomUUID()` in `localStorage`), at least 16 characters. Ids that
  start with `c_` (derived from a tracked-link click) are refused with `400
  invalid_anonymous_id`, because a click id travels in a shareable URL.

### 2.3 `POST /public/forms/:orgId/:formId/submit`

Runs extraction and qualification, creates (or matches) the record the form
says it creates, and returns the offer token when the visitor qualifies.

**What gets created is a form setting, not a guess.** `GET` on the form
returns `creates`:

| `creates.record` | A submit writes |
|---|---|
| `lead` (default) | a lead, matched by email |
| `account` | an account with the visitor as its first person, matched by email, plus an `intake_submission` record holding this application (answers, summary, attribution). No lead. |

A returning visitor (same email, any capitalization) is matched, never
duplicated. The response's `receipt` says exactly what happened.

An `account` form can also say who else belongs on the account and what the
visitor is to it. Both are form settings in Funal:

- `primary_roles`: the visitor's roles, for example `["guardian"]`.
- `account_people`: other people to add, for example the child a parent
  applied for: `[{ "name": "{{child_first_name}} {{last_name}}", "roles": ["child"] }]`.
  The name fills from `first_name`, `last_name` and the qualification fields
  (an `answer_map` `$pattern` can pull `child_first_name` out of
  "My daughter Maya, age 14."). If any placeholder is empty, that person is
  skipped. These people have no email or phone, so they are matched by name on
  the account: a returning family gets no second child.

Roles come from the org's role list. A role the list lacks never fails the
application: the visitor is written without it, and an extra person that
can't be added is left out of the receipt.

Request:

```json
{
  "firstName": "Dana",
  "lastName": "Reyes",
  "email": "dana@example.com",
  "phone": "+15555550123",
  "values": { "situation": "…", "duration": "…" },
  "transcript": [
    { "turn": 1, "key": "situation", "question": "What's going on right now?", "options": ["Focus"], "answer": "…", "method": "chip", "ms": 4200 }
  ],
  "utm": { "utm_source": "fb", "utm_campaign": "spring" },
  "anonymous_id": "a1b2c3",
  "click_id": "…",
  "link_variant": "…",
  "variant": "…",
  "lt": "…",
  "submissionId": "9b2f…",
  "dryRun": false
}
```

- `email` is required unless `lt` identifies an invited lead.
- `values` is the same `answers` map you built during the interview.
- `transcript` (optional, up to 60 entries) is stored on the lead so the
  interview can be replayed in Funal. `method` is free text such as `chip`
  or `typed`.
- `utm`, `click_id`, `link_variant`, `anonymous_id` are attribution. Pass
  what you have.
- `submissionId` (optional, recommended): your id for this application, for
  example a `crypto.randomUUID()` made when the visitor starts. Sending the
  same id again (a retry, a double tap) returns the first response with
  `"replayed": true` and a fresh `leadToken`, without writing anything or
  re-running the model.
- `dryRun: true` runs everything (extraction, qualification, matching) and
  returns what **would** be created, writing nothing and returning no
  `leadToken`. Use it while building and testing.

Response:

```json
{
  "data": {
    "receipt": {
      "record": "account",
      "account": { "id": "…", "created": true },
      "person": { "id": "…", "created": true, "matched": null },
      "people": [{ "id": "…", "roles": ["child"], "created": true }]
    },
    "qualified": true,
    "action": "book",
    "summary": "Internal one-paragraph summary for the team",
    "reflection": "What we heard, written for the visitor",
    "qualification": { "intent": "ready", "affordability": "yes", "…": "…" },
    "tier": "hot",
    "leadToken": "eyJ…",
    "deposit": { "amountCents": 20000, "currency": "usd", "label": "Meet & Greet", "blurb": "…" },
    "mng": { "poolSlug": "coaches", "providerId": null, "minGapHours": 24, "label": "Meet & Greet" }
  }
}
```

- `receipt` is what this submit created or matched. `created: false` with
  an id means an existing record was matched (`matched: "email"`). For a
  lead-mode form it is `{ "record": "lead", "lead": { "id", "created" } }`
  and `leadId` is also returned at the top level. `people` (account forms
  with `account_people` only) lists the other people put on the account, in
  the form's order. In a `dryRun`, ids are what would be matched and `null`
  where something would be created.
- `action` is `book` when qualified, otherwise `nurture`. Render the
  matching end state from `copy`.
- `reflection` is safe to show the visitor. `summary` and `qualification`
  are for your own use; do not display them verbatim.
- **Forms with an answer map decide instantly.** When the form owner sets
  `intake_config.answer_map` (each tappable option names the fields it sets),
  who qualifies and their `tier` come from the tapped answers alone: exact, in
  milliseconds, and unaffected by a model outage. A qualified visitor then gets
  the response right away with `summary` and `reflection` empty; the team's
  summary is written to the record a few seconds later. A visitor who does not
  qualify still gets a `reflection` for the nurture screen. Send options as
  shown (`"Premium"`, or `"Premium ($1,500 to $3,000 a month)"`): an option
  followed by its description still matches.
- `leadToken` is present only when qualified and the form has a booking
  target. It is valid for **24 hours** and is the credential for every
  booking call in section 3.2. `tier` is `hot` (book any slot) or `warm`
  (earliest slots are held back for hot leads).
- `deposit` and `mng` appear only on forms configured with a paid
  Meet & Greet. Most forms will not return them. If present, the flow is:
  create a deposit intent, book the admissions call, then book the
  Meet & Greet at least `minGapHours` later with `bookingKind: "meet_and_greet"`
  and the `depositIntentId`.

Submitting the same email twice does not create a second lead. The server
appends to the existing lead's notes and refreshes its qualification.

### 2.4 `POST /public/forms/:orgId/:formId/upload`

Attaches a document to an invited lead. **Requires** a valid `lt` token in the
query string; anonymous visitors cannot upload.

```
POST $BASE/public/forms/:orgId/:formId/upload?lt=…&name=dd214.pdf&category=service_record
Content-Type: application/pdf
<raw bytes>
```

- Accepted types: `application/pdf`, `image/png`, `image/jpeg`,
  `image/heic`, `image/webp`. Max 15 MB. Max 12 files per lead (then `429`).
- Response: `{ "data": { "id": "…", "name": "dd214.pdf", "size_bytes": 12345 } }`
- `401 invalid_or_expired_link` if the token is missing or bad.

### 2.5 `POST /public/forms/:orgId/:formId/preview`

Runs extraction and qualification against sample answers **without creating
a lead**. Use it to test a variant.

Request: `{ "variant": "b", "values": { "situation": "…" } }`

Response:

```json
{
  "data": {
    "config": { "source": "atom", "variant": "b", "mode": "interview", "maxTurns": 6, "extractionKey": "qualification", "leadAtomKind": "lead" },
    "qualification": { "…": "…" },
    "qualified": true,
    "action": "book"
  }
}
```

This endpoint calls the model, so do not put it on a hot path.

### 2.6 Personal invite links (`lt`)

An operator can mint a per-lead invite token with `form_invite` (MCP). The
token encodes the lead, org, and form. `form_invite` defaults to 30 days
(max 90); `scheduling_link` defaults to 365 days and can mint permanent links. When you pass it:

- `GET` returns `invite` with the lead's name and email for prefill.
- `/submit` does not require `email` and writes to the invited lead.
- `/upload` becomes available.

The Funal-hosted form reads it from `?lt=`. Your UI can carry it however you
like, but pass it as `lt` in the query on `GET` and `/upload` and in the body
on `/next` and `/submit`.

### 2.7 Authoring forms with an API key

There is no dedicated forms REST API. You have two options.

**Option A: MCP tools (recommended).** Funal exposes an MCP server at
`POST $BASE/mcp` (Streamable HTTP transport). Authenticate with the same
`Authorization: Bearer` header. Use any MCP client, or raw JSON-RPC:

```bash
curl -s $BASE/mcp \
  -H "Authorization: Bearer $KEY" -H "X-Funal-Org-Id: $ORG" \
  -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"form_list","arguments":{}}}'
```

| Tool | Purpose |
|---|---|
| `form_authoring_guide` | Returns the operating manual for form authors. Read it first. |
| `form_list` | Lists forms: `form_id`, `variant`, `active`, `max_turns`, booking target. Pass `form_id` to filter. |
| `form_validate` | Validates a draft without saving. |
| `form_upsert` | Saves a draft variant, keyed by `(form_id, variant)`. Always saved inactive. |
| `form_preview` | Same as `/preview` above. |
| `form_activate` | Promotes a variant to active. `keep_others: true` leaves siblings active. |
| `form_invite` | Mints a personal `lt` link for a lead: `{ form_id, lead_id, ttl_days? }`. |
| `flow_start`, `flow_answer`, `flow_submit`, `flow_suggest`, `flow_book`, `flow_downsell`, `flow_state`, `flow_reset` | Walk the live public endpoints as a synthetic `@qa.funal.test` persona. `flow_book` is a dry run. |

`form_upsert` / `form_validate` accept these fields (all strings unless
noted):

- Identity: `form_id` (required), `name`, `variant`, `mode` (`interview` | `collect`).
- Interview brain: `next_question_instruction`, `max_turns` (1..12).
- Submit brain: `extraction_key`, `extraction_schema` (JSON string, a JSON
  Schema object), `extraction_instruction`, `qualified_when` (JSON string:
  `{ "field", "op", "value" }` with `op` in `eq neq in not_in is_set is_empty`).
- Must-ask closers: `ask_slots` (JSON string). An array of up to 6
  `{ signal, question, description?, guidance?, values?, answer_key?, field_type?, options? }`.
  Each `signal` is something the interview may not end without knowing. The
  model reports its read of every signal each turn (that is the `known`
  object). Any signal still `unknown` when the story wraps is asked directly
  using `question`, with `options` as chips. Name each signal after the
  `extraction_schema` field it feeds so `qualified_when` can gate on it.
  Without `ask_slots` the interview ends whenever the model decides it is done.
- Tiering: `hot_when` (JSON string, same shape as `qualified_when`) decides
  which qualified leads are `hot`. `prime_window_hours` (0..336, default 48)
  is how much near-term calendar is held back from `warm` leads. With no
  `hot_when`, the rule is `intent eq ready`.
- Committed path (optional): `deposit` (JSON string:
  `{ applies_to_tier, amount_cents, currency?, label, blurb?, booking_type? }`)
  and a second meeting target: `mng_pool_slug` or `mng_provider_id`,
  `mng_label`, `mng_min_gap_hours`. The payment gateway is a mock today.
- Lead: `lead_atom_kind` (default `lead`).
- Routing: `book_pool_slug` or `book_provider_id`, `book_duration_minutes`
  (5..480), `book_optional` (boolean).
- Copy: every key shown under `copy` in 2.1, plus `opener_options` (JSON
  string array, up to 12) and `contact_fields` (JSON string array of
  `{ key, label?, type?: text|email|tel, required? }`; keys match
  `^[a-z][a-z0-9_]{0,39}$`).

**Option B: raw records.** `intake_config` is an ordinary atom, so the
generic Atoms API works: `GET /atoms/intake_config`,
`POST /atoms/intake_config`, `PATCH /atoms/intake_config/:id`. See
`GET $BASE/docs` for the Atoms API. The MCP tools add validation and the
one-active-variant rule; prefer them.

---

## 3. Scheduling

### How Funal scheduling works

- A **provider** is a team member with a `provider_availability` record:
  timezone, weekly schedule, date overrides, buffers, minimum notice, and
  connected Google calendars. Slots are computed live from the schedule
  minus Google free/busy minus existing Funal bookings. If the calendar
  check fails, the day fails closed and returns no slots.
- A **pool** is a named group of providers with a slug. Availability is the
  union of members' free slots; a booking is assigned to the least-loaded
  member at book time.
- A **booking** is a record with `starts_at`, `duration_minutes`, `status`
  (`scheduled`, `completed`, `cancelled`, `late_cancel`, `no_show`), the
  provider, and the subject (lead or client). After creation a background
  lifecycle mints manage links, creates the Zoom meeting (if configured) and
  the Google Calendar event, and emails a confirmation with reschedule and
  cancel links.
- Slot times are ISO 8601 strings in UTC. `timezone` in responses is the
  provider's or pool's IANA zone; use it to render local times.

### 3.1 Public: discover

#### `GET /public/booking/:orgId/config`

Branding plus the publicly bookable providers and products.

Query: `providerId` (optional; restricts to one provider).

```json
{
  "orgName": "Wide Awake",
  "brandColor": "#…",
  "logoUrl": "https://…",
  "providerLabel": "coach",
  "products": [{ "id": "…", "name": "Intro call", "description": "…", "durationMinutes": 30 }],
  "providers": [{ "id": "user_…", "name": "Taylor Jones", "imageUrl": "…", "availableDays": ["mon", "tue"] }],
  "defaultDuration": 60
}
```

Only providers marked publicly bookable appear. Pools are not listed here;
you get pool slugs from the form's `booking.poolSlug`, from a link, or from
`scheduling_targets` over MCP.

#### `GET /public/booking/:orgId/availability`

Slots for one provider on one day.

Query: `providerId` (required), `date` (`YYYY-MM-DD`, in the provider's
timezone), `duration` (minutes, default 60), `lt` (offer token, optional).

```json
{
  "slots": ["2026-10-02T15:00:00.000Z", "2026-10-02T15:30:00.000Z"],
  "available_days": ["mon", "tue", "wed"],
  "timezone": "America/New_York",
  "duration": 30
}
```

Without `date` you get only `available_days`, which is enough to grey out
weekdays in a calendar picker.

**Several days in one call.** Add `days` (2..14) and `date` becomes the first
day of a range. The response gains a `days` array, one entry per consecutive
date, and `slots` stays as day one:

```json
{
  "slots": ["2026-10-02T15:00:00.000Z"],
  "days": [
    { "date": "2026-10-02", "slots": ["2026-10-02T15:00:00.000Z"] },
    { "date": "2026-10-03", "slots": [] },
    { "date": "2026-10-04", "slots": ["2026-10-04T14:00:00.000Z", "2026-10-04T14:30:00.000Z"] }
  ],
  "available_days": ["mon", "tue", "wed"],
  "timezone": "America/New_York",
  "duration": 30
}
```

A day off or a fully booked day is an empty `slots` array. Use this to mark
which dates in a calendar actually have openings. For a month view, make
three calls of 14, 14 and the remainder. `days` without a valid `date` is
`400`. With an offer token, slots outside the
lead's tier window are removed. Errors: `400` missing providerId, `401` bad
token, `404` provider not public, `500` calendar error.

#### `GET /public/booking/:orgId/pool/:slug`

Same as availability, for a pool. Query: `date`, `duration` (defaults to
the pool's configured duration), `days` (2..14, same range behavior as
above), `lt`.

```json
{
  "pool": { "slug": "admissions", "name": "Admissions team", "page_title": "Book your call" },
  "members": [
    { "name": "LaRissa Bertram", "photoUrl": "https://…" },
    { "name": "Khloe Smith", "photoUrl": null }
  ],
  "duration": 30,
  "slots": ["…"],
  "available_days": ["…"],
  "timezone": "America/New_York"
}
```

What a pool's bookings *are* is the pool's setting, not the page's: a pool
with `booking_type` (for example `intro` for an admissions team) stamps every
booking made through it with that type and ignores `bookingKind`, and its
`booking_title` names the calendar event ("Admissions Interview - Jane Doe").
A booking made with a booking pass also records the form it came from
(`form_id`).

`members` is who is on the pool, for a "you'll meet one of our team" line.
Slots are the union of everyone's openings, and which member takes a slot is
decided when it is booked, so members are not tied to slots. `photoUrl` is a
real photo or `null` (draw your own initials), never a generated image.
The booking response then names the actual person (`providerName`,
`providerPhotoUrl`).

#### `POST /public/booking/:orgId/suggest`

Returns up to three well-spread slots with a short model-written sentence,
for a "here are a few times" UI instead of a calendar grid.

Request: `{ "providerId" | "poolSlug", "durationMinutes": 30, "leadToken"?, "prefer"?: "later_day" | "later_week" }`

Response:

```json
{
  "message": "Dana, Taylor has a few openings this week:",
  "timezone": "America/New_York",
  "slots": [{ "startsAt": "2026-10-02T15:00:00.000Z", "label": "Thu Oct 2, 11:00 AM" }],
  "hosts": [{ "userId": "user_…", "name": "Taylor Jones", "headline": "…", "bio": "…", "photoUrl": "…" }]
}
```

Read-only. Note the default duration here is 30, not 60.

### 3.2 Public: book

#### `POST /public/booking/:orgId/bookings`

Request:

```json
{
  "providerId": "user_…",
  "poolSlug": null,
  "startsAt": "2026-10-02T15:00:00.000Z",
  "durationMinutes": 30,
  "firstName": "Dana",
  "lastName": "Reyes",
  "email": "dana@example.com",
  "phone": "+15555550123",
  "notes": "…",
  "topic": "…",
  "productId": "…",
  "serviceId": "…",
  "guestEmails": ["partner@example.com"],
  "leadToken": "eyJ…",
  "bookingKind": "admissions_call",
  "depositIntentId": "…"
}
```

- Exactly one of `providerId` or `poolSlug`.
- `startsAt` must be a slot returned by availability. The server
  re-validates against the live calendar.
- `durationMinutes`: 15..480, default 60. Send the same value you used for
  availability.
- Identity: either `leadToken` (books as the intake lead, no contact fields
  needed) or `firstName` + `email` + `phone`.
- `guestEmails`: up to 5, receive calendar invitations.
- `bookingKind` and `depositIntentId`: only for the deposit / Meet & Greet
  flow described in 2.3.

Response:

```json
{
  "data": {
    "success": true,
    "bookingId": "…",
    "startsAt": "2026-10-02T15:00:00.000Z",
    "durationMinutes": 30,
    "providerName": "Taylor Jones",
    "providerPhotoUrl": "https://…/taylor.jpg",
    "actionToken": "Ym9va2luZzo…"
  }
}
```

`providerPhotoUrl` is the person they will meet: a real photo, or `null` when there is none, so you can draw your own placeholder. `actionToken` is the booker's handle on the booking they just made. Keep it
on the confirmation screen and use it with the manage endpoints in 3.3 to
show the meeting link, offer "reschedule" and "cancel", and build an
add-to-calendar link. It is the same token the confirmation email carries,
valid until 30 days after the session. It may be absent on an environment
without booking links configured, so treat it as optional.

Errors:

| Status | `code` | Meaning |
|---|---|---|
| 400 | | missing fields, invalid `startsAt`, time in the past, bad duration |
| 401 | | bad or expired `leadToken` |
| 402 | `deposit_required`, `deposit_unpaid` | Meet & Greet needs a paid deposit |
| 404 | | provider not public, pool or lead not found |
| 409 | `slot_taken` | someone else took it; refresh availability |
| 409 | `outside_availability` | not a valid slot for this provider |
| 409 | `outside_offer` | slot is outside the lead's tier window |
| 409 | `before_gap` | Meet & Greet is too soon after the admissions call |
| 503 | `calendar_error` | Google check failed; retry |

On `409` refetch availability and ask the visitor to pick again.

#### `POST /public/booking/:orgId/deposit-intent` and `/downsell`

Both take `{ "leadToken" }` and are only relevant when `/submit` returned
`deposit`. `deposit-intent` returns
`{ data: { intentId, clientSecret, amountCents, currency, status, mock } }`.
`downsell` re-issues the token as `warm` tier:
`{ data: { leadToken, tier: "warm" } }`. The payment gateway is currently a
mock; treat this flow as preview.

### 3.3 Public: manage an existing booking

An **action token** encodes one booking and its org and is valid until 30
days after the session. You get it two ways: as `actionToken` in the
`POST /bookings` response, or as the last path segment of the reschedule and
cancel links in the confirmation email. With it your UI calls:

| Method and path | Body | Response |
|---|---|---|
| `GET /public/booking/:orgId/action/:token` | | `{ data: { title, startsAt, durationMinutes, status, providerName, providerPhotoUrl, meetingUrl?, orgName, brandColor, logoUrl } }`. `meetingUrl` (Zoom or Meet) is created a few seconds after booking, so poll this once or twice on the confirmation screen. Cancelled or completed bookings return only `title`, `status`, branding. |
| `GET /public/booking/:orgId/action/:token/availability?date=&duration=` | | same shape as availability; the booking's own slot is excluded from busy time |
| `POST /public/booking/:orgId/action/:token/reschedule` | `{ "newStartTime": "<ISO>" }` | `{ data: { success, bookingId, startsAt } }` |
| `POST /public/booking/:orgId/action/:token/cancel` | `{ "reason"?: "…" }` | `{ data: { success: true, status: "cancelled" } }`. Idempotent. |

Errors: `401` invalid token, `410` expired, `404` booking not found, `409`
booking is not in `scheduled` status.

There is also an older `/manage/:token` family that returns server-rendered
HTML. Do not build on it.

### 3.4 Control plane: `/scheduling` with an API key

Authenticated, provider-only (no pools), server-to-server. These are the
routes the Funal app itself uses for in-app booking.

#### `GET /scheduling/availability?providerId=&date=&duration=`

Same response shape as the public route. Differences: no public-bookable
gate (any provider in the org), no offer-token filtering.

#### `POST /scheduling/bookings`

```json
{
  "providerId": "user_…",
  "startsAt": "2026-10-02T15:00:00.000Z",
  "durationMinutes": 60,
  "clientId": "…",
  "leadId": "…",
  "contactId": "…",
  "firstName": "…", "lastName": "…", "email": "…", "phone": "…",
  "productId": "…", "serviceId": "…",
  "type": "regular",
  "title": "…",
  "notes": "…",
  "attendeeEmails": ["…"],
  "sendCalendarEmails": true,
  "createZoom": true,
  "createCalendar": true,
  "allowOverbook": false
}
```

Subject resolution, in priority order: `clientId`, then `leadId`, then
`firstName` + `email` + `phone` (finds or creates a client by email, then
phone). `attendeeEmails` up to 20. `allowOverbook: true` skips the busy
check and flags the booking. Response and errors match the public route.

#### `POST /scheduling/series`

Books a recurring series for a client in one call.

```json
{
  "providerId": "user_…",
  "clientId": "…",
  "serviceId": "…",
  "durationMinutes": 60,
  "sessions": [{ "sessionNumber": 1, "startsAt": "…" }, { "sessionNumber": 2, "startsAt": "…" }],
  "timezone": "America/New_York",
  "eventTitle": "…",
  "seriesId": "…",
  "supersede": false,
  "dryRun": false,
  "override": false,
  "attendeeEmails": [],
  "excludeBookingIds": []
}
```

Up to 50 sessions. `409 series_conflict` if a series with that id exists
and `supersede` is false. `dryRun: true` validates without writing.

#### `POST /scheduling/reschedule-batch` and `/reschedule-batch/preview`

```json
{ "sessions": [{ "bookingId": "…", "startsAt": "…", "durationMinutes"?: 60 }], "silent"?: false, "override"?: false, "dryRun"?: false }
```

1..200 sessions. `/preview` forces `dryRun`. Response:
`{ data: { dryRun, providerNotices, total, succeeded, failed, results } }`.
Each result carries `calendarUpdated` and `calendarNote`; a failed calendar
patch does not roll back the booking.

#### `POST /scheduling/cancel-batch`

```json
{ "sessions": [{ "bookingId": "…" }], "outcome"?: "cancelled_in_time" | "late_cancel" | "provider_cancelled", "reason"?: "…", "silent"?: false, "removeCalendar"?: true, "removeMeeting"?: true, "payable"?: false }
```

Response: `{ data: { total, succeeded, failed, results } }`.

### 3.5 Control plane: scheduling over MCP

The MCP tools cover everything the HTTP control plane does, plus pools,
targets, configuration, and links. Same `POST $BASE/mcp` transport as 2.7.

| Tool | Purpose |
|---|---|
| `scheduling_targets` | List every bookable target: providers (with names) and pools. Start here. |
| `scheduling_config` | Raw availability config for a provider or pool. |
| `scheduling_availability` | Slots for `provider_id` or `pool_slug`; `date` or `days` (1..30); optional `tier` and `prime_window_hours` to simulate an offer. |
| `scheduling_status` | Next-N-days summary of open slots for a target (`horizon_days` 1..30). |
| `scheduling_check` | Is this exact `starts_at` bookable for this target. |
| `scheduling_book` | Book a lead: `{ provider_id | pool_slug, lead_id, starts_at, duration_minutes?, type?, notes?, allow_overbook? }`. Pools assign the least-loaded member. |
| `scheduling_book_series` | Series for a client, up to 50 sessions. |
| `scheduling_reschedule` | `{ booking_id, starts_at, duration_minutes?, allow_overbook? }` |
| `scheduling_cancel` | `{ booking_id, reason? }` |
| `scheduling_manage_link` | Reschedule/cancel link for a booking (`short: true` for a go.funal.link URL). |
| `scheduling_link` | A personal booking link for a lead through a form: `/apply/:org/:form?p=…|pool=…&d=…&lt=…`. |
| `scheduling_page_link` | A prefilled link to the hosted booking page: `/book/:org/:provider?p=<productId>&d=&topic=&name=&email=&phone=`. |
| `scheduling_refresh_invite` | Rewrite the calendar invite description for a booking. |
| `booking_drift_check` | Compare bookings to their Google events and report drift. |

---

## 4. End-to-end recipes

### Hosted-style intake with booking, in your own UI

1. `GET /public/forms/:org/:form` → render intro, opener, contact card.
2. Loop: collect answer → `POST /next` with `turn`, `answers`, `known` →
   render question → until `done: true`.
3. `POST /submit` with contact fields, `values`, `transcript`, attribution.
4. If `action === "book"`: pick the target from the form's `booking`
   (`poolSlug` or `providerId`) and show slots with
   `GET /pool/:slug?date=&lt=<leadToken>` or `GET /availability?providerId=&date=&lt=<leadToken>`,
   or `POST /suggest` with `leadToken`.
5. `POST /bookings` with `startsAt`, `durationMinutes`, the target, and
   `leadToken`. Show `copy.booked_title` and `booked_body`. Keep the
   returned `actionToken` and call `GET /action/:token` for the meeting link.
6. If `action === "nurture"`: show `copy.nurture_title` and `nurture_body`.

### Plain booking page, no form

1. `GET /public/booking/:org/config` → provider and product pickers.
2. `GET /availability?providerId=&date=&duration=` per selected day.
3. `POST /bookings` with contact fields.

### Reschedule or cancel from an email link

1. Use the `actionToken` from the booking response, or parse the token from
   the link your visitor clicked.
2. `GET /action/:token` → show the booking.
3. For reschedule: `GET /action/:token/availability?date=` then
   `POST /action/:token/reschedule`. For cancel: `POST /action/:token/cancel`.

### Operator books for a known client (server-side)

`POST /scheduling/bookings` with your API key, `providerId`, `clientId`,
`startsAt`. Or `scheduling_book` over MCP for a lead or a pool.

---

## 5. Errors and limits

- Error bodies are `{ "error": "<message or code>", "code"?: "<code>" }`.
  Public booking routes put the machine code in `code`; other routes put it
  in `error`.
- `401 unauthorized` on the control plane means a missing or invalid bearer
  key. `400 multiple_organizations` (with a list of your orgs) means add
  `X-Funal-Org-Id`.
- Offer tokens (`leadToken`) expire 24 hours after submit. Invite tokens
  (`lt`) expire per their TTL. Action tokens expire 30 days after the
  session.
- String inputs are trimmed, stripped of control characters, and capped
  (names 200, email 300, phone 50, notes 1000, answers 800 per value).
- Throttle on your side. Treat the public routes like any shared API: debounce
  repeated calls, never loop on `/next`, and keep `/preview` and `/suggest`
  (they call a model) off hot paths.
- `/next` and `/submit` are blocking model calls with no streaming. Expect
  about one to two seconds per question and a few seconds on submit. Design
  a thinking state between questions and a processing screen on submit.
- `interview`, `days`, `actionToken`, `meetingUrl` and the `/draft` endpoint
  were added on 2026-09-30 and are live on the production API. Still treat
  them as optional in your client so an older environment never crashes the
  page.
- **A `dryRun` booking writes nothing.** Send `"dryRun": true` with a
  `…@qa.funal.test` email (any other email is refused with `dryrun_not_qa`,
  so a real visitor is never told they are booked when they are not). Every
  check runs: slot, pool assignment, deposit, offer window. The response has
  `dryRun: true`, the rep's name and photo, a placeholder `bookingId`
  (`dryrun:…`) and `preview`, the booking that would have been saved. No
  booking, client, calendar event or email is created, and no booking
  automation fires. There is no `actionToken`, so the reschedule and cancel
  flows need one real booking to verify.
- Default durations differ by endpoint: availability and bookings default to
  60 minutes, `suggest` and the MCP tools default to 30, pools default to
  their configured duration. Always pass `duration` explicitly.
