> ## Documentation Index
> Fetch the complete documentation index at: https://flywheel.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Webinars over MCP

> Sessions, recurring series, registrants, attendance, the send plan, revenue and platform links, through the webinar tools on the Flywheel MCP server

The webinar tools on `https://api.flywheel.cx/mcp` cover everything the dashboard's Webinars area does: sessions on the calendar, recurring series that build sessions ahead, registrants and attendance, the plan of messages each registrant receives, revenue, and the link to the platform the session runs on (Demio, WebinarJam or Zoom).

They are thin wrappers over the same functions the REST endpoints use, so behaviour matches [the Webinars API](/api-reference/webinars) exactly. Read tools need the `webinars.read` permission; write tools need `webinars.write`. For the rest of the server see [the MCP overview](/mcp/overview).

<Warning>
  `register_for_webinar` and `set_webinar_attendance` send real messages to real people. `update_webinar` with a new `start_time` or `status: "canceled"` moves or stops messages people are waiting for. Confirm with the human you are working for before calling any of them on a session that has, or will have, real registrants.
</Warning>

## The webinar model: sessions, series and occurrences

Flywheel's webinar vocabulary, as the webinar tools use it:

* **Session (a "webinar").** One event on the calendar: one `org_webinars` row with a `title`, `start_time`, optional `duration_minutes`, `timezone` and `join_url`, a `status`, and optionally a link to a platform. Every tool that takes `webinar_id` takes a session id. The older REST endpoints call the same object a webinar; the id is the same.
* **Series.** A repeat rule (a cadence in a timezone) that keeps `build_ahead_count` future sessions built and open for sign-ups. It builds each one by copying its **template session**: title, duration, join link, timezone and the session's own messages. The template is the first session, created with the series.
* **Occurrence.** One date on a series' cadence, written as a local date `yyyy-MM-dd` in the series timezone. A built session carries it as `series_occurrence_date`. An occurrence can be built (it has a session), unbuilt (still ahead, not built yet), or skipped.
* **Materialize.** The act of building the sessions a series is short of. It runs on its own just after each series session starts, after every series write, and on demand with `materialize_webinar_series`.
* **The session's own messages.** Broadcasts tied to the session and timed from its start or end ("1 day before start", "2 hours after it ends"). They go to the session's registrants at a fixed moment. Create them with the broadcast tools; see [Broadcasts over MCP](/mcp/broadcasts).
* **Bound sequences and workflows.** Automations whose trigger is `webinar_registration` (fires per sign-up) or `webinar_attendance` (fires after the session ends, per registrant whose outcome matches). They are bound to one session, to every session of a series, or to any webinar. See [Workflows over MCP](/mcp/workflows).
* **Person.** A contact. `person_id` is the person UUID from `search_people`, `list_people` or `identify_person` ([People over MCP](/mcp/people)).

Session statuses are `draft`, `scheduled`, `live`, `completed` and `canceled`. Series statuses are `active`, `paused` and `archived`.

Ids are UUIDs read from list and get tools. Never invent one. The only ids you generate yourself are the optional idempotency keys (`webinar_id` on `create_webinar`, `new_webinar_id` on `clone_webinar`, `series_id` on `create_webinar_series`), which must be fresh v4 UUIDs.

## Webinar tools at a glance

| Tool | Writes? | What it does |
| - | - | - |
| `list_webinars` | No | Sessions, filtered by status, series and start window |
| `get_webinar` | No | One session with registration counts and its messages by status |
| `create_webinar` | Yes | A one-off session |
| `update_webinar` | Yes | Edit, reschedule, change status, cancel or restore a session |
| `delete_webinar` | Yes | Delete a session that never gathered anything |
| `clone_webinar` | Yes | Copy a session and its messages (as drafts) to a new start |
| `list_webinar_registrants` | No | Registrants with attendance, watch time, clicks and purchases |
| `register_for_webinar` | Yes, sends messages | Register a person |
| `set_webinar_attendance` | Yes, sends messages | Mark a registrant attended or no-show |
| `get_webinar_plan` | No | Everything the session sends, resolved for given registration moments |
| `get_webinar_revenue` | No | What one session earned |
| `link_webinar_platform` | Yes | Link a session to Demio, WebinarJam or Zoom |
| `unlink_webinar_platform` | Yes | Remove the platform link |
| `list_webinar_series` | No | Series with cadence and status |
| `get_webinar_series` | No | One series with its sessions, skips and next unbuilt dates |
| `create_webinar_series` | Yes | A recurring webinar |
| `update_webinar_series` | Yes | Change a series' rule or status |
| `materialize_webinar_series` | Yes | Build missing sessions now |
| `skip_webinar_occurrence` | Yes | Stop a series building one upcoming date |
| `unskip_webinar_occurrence` | Yes | Put a skipped date back |
| `get_webinar_series_revenue` | No | What a series earned, deduplicated |

## Listing and reading sessions: list\_webinars and get\_webinar

`list_webinars` pages through sessions. Every argument is optional:

* `status`: one of `draft`, `scheduled`, `live`, `completed`, `canceled`.
* `series_id`: only sessions a series built (the template included).
* `starts_after`: sessions starting at or after this ISO-8601 instant. `starts_before`: sessions starting before it.
* `order`: `asc` or `desc` by start time. Default `desc` (latest first).
* `limit`: 1 to 200, default 50. `offset`: default 0. Out-of-range values are refused, not clamped.

For "what is coming up", pass `starts_after` as now and `order` as `asc`:

```json theme={null}
{
  "starts_after": "2026-10-08T00:00:00Z",
  "status": "scheduled",
  "order": "asc",
  "limit": 20
}
```

It returns `{ "webinars": [...], "total": 37 }`. Each session carries `id`, `title`, `status`, `start_time`, `end_time` (start plus duration, `null` while the duration is unknown), `timezone`, `duration_minutes`, `join_url`, `series_id`, `series_occurrence_date`, `automation_id`, `platform` and `created_at`. `platform` is `null` when the session is not linked, otherwise `{ "provider": "zoom", "event_id": "81234567890", "date_id": null }`.

`get_webinar` takes `webinar_id` and returns `{ "webinar": {...} }` with the same fields plus:

* `series`: `{ id, name, status }` when the session belongs to a series, else `null`.
* `registrations`: `{ registered, attended, no_show, unresolved }`. `no_show` counts people whose attendance was resolved and who did not attend; `unresolved` is registered with attendance not known yet. These are exact database counts.
* `messages`: the session's own broadcasts by status: `{ total, draft, scheduled, sending, sent, stopped, error }`.

```json theme={null}
{ "webinar_id": "0d4f6f36-2b6a-4c1e-9a6f-1f2d3c4b5a69" }
```

`get_webinar` counts messages but does not show what they say or when they land. Use `get_webinar_plan` for that.

## Creating a one-off session: create\_webinar

`create_webinar` makes one session. For something that repeats, use `create_webinar_series` instead.

| Argument | Required | Notes |
| - | - | - |
| `title` | Yes | Non-empty |
| `start_time` | Yes | ISO-8601 with an offset, e.g. `2026-10-20T19:00:00-04:00` |
| `timezone` | No | IANA name such as `America/New_York`; invalid names are refused |
| `duration_minutes` | No | Positive integer. Messages timed from "the end" count from start plus duration |
| `join_url` | No | Must be a valid URL |
| `status` | No | `draft` or `scheduled`. Default `scheduled` |
| `automation_id` | No | A workflow id (from `list_workflows`) to label the session with. Informational only: it enrolls nobody. A sequence id is refused |
| `webinar_id` | No | A v4 UUID you generate, used as the new session's id and the idempotency key |

```json theme={null}
{
  "webinar_id": "5b1e0a2c-7d4f-4e8a-9c3b-2f6a1d8e4b70",
  "title": "Pricing Teardown Live",
  "start_time": "2026-10-20T19:00:00-04:00",
  "timezone": "America/New_York",
  "duration_minutes": 60,
  "join_url": "https://example.com/live"
}
```

It returns `{ "webinar": {...}, "created": true }`. Calling again with the same `webinar_id` writes nothing and returns the existing session with `created: false`, so a retry after a timeout is safe. If that id belongs to another organization's session, the call fails with "already exists".

A new session has no messages, no registrants and no platform link. Next steps: link it with `link_webinar_platform`, add its messages with the broadcast tools, make sure a sequence or workflow is bound to it, then check the result with `get_webinar_plan`.

## Editing, rescheduling and status: update\_webinar

`update_webinar` patches one session. Omitted fields stay as they are; `null` clears `timezone`, `duration_minutes`, `join_url` or `automation_id`. Accepted fields: `title`, `start_time`, `timezone`, `duration_minutes` (1 to 1440), `join_url`, `status`, `automation_id`. Unknown fields are refused.

```json theme={null}
{
  "webinar_id": "0d4f6f36-2b6a-4c1e-9a6f-1f2d3c4b5a69",
  "title": "Pricing Teardown Live (encore)",
  "join_url": "https://example.com/live-2"
}
```

It returns `{ "webinar": {...}, "schedule_effects": null }`, or a `schedule_effects` object when the start or duration changed (see the next section). A call that changes nothing returns the session untouched.

Editing a session never touches the series it belongs to. Editing a series' template session does change what the series builds from then on.

### What rescheduling does to planned messages (update\_webinar start\_time and duration\_minutes)

A new `start_time` (a reschedule) or a new `duration_minutes` re-anchors everything timed off the session, and the response reports what moved:

```json theme={null}
{
  "webinar": { "id": "0d4f6f36-2b6a-4c1e-9a6f-1f2d3c4b5a69", "start_time": "2026-10-27T23:00:00.000Z" },
  "schedule_effects": {
    "waits_recomputed": 42,
    "messages_rescheduled": true,
    "attendance_sync_rearmed": true
  }
}
```

* `waits_recomputed`: pending waits in running workflow and sequence runs that are anchored to the session start ("wait until 1 hour before start") moved onto the new start. Only a start change moves these; a duration change alone reports 0.
* `messages_rescheduled`: the session's own messages that are timed from its start or end were re-scheduled. A duration change moves the "after it ends" ones even when the start stays put.
* `attendance_sync_rearmed`: the linked platform's post-session attendance pull was moved to the new end time. `false` when the session is not linked.

Steps timed relative to each person's own registration ("2 days after they register") are not anchored to the session and do not move.

A reschedule tells nobody by itself. If registrants should hear about the new time, that is a message you plan and schedule separately, after the human agrees to the wording.

<Note>
  Rescheduling one session of a series moves only that session. The series' cadence is unchanged, and the session keeps its `series_occurrence_date`.
</Note>

### Session status moves and cancelling (update\_webinar status)

`status` moves are validated. Anything else is refused with the moves that are allowed:

| From | Can move to |
| - | - |
| `draft` | `scheduled`, `canceled` |
| `scheduled` | `live`, `completed`, `canceled` |
| `live` | `completed`, `canceled` |
| `canceled` | `scheduled` (restore) |
| `completed` | nothing: it is final. Clone it to run it again |

A scheduled session cannot go back to `draft` (people may already be registered); cancel it instead.

```json theme={null}
{ "webinar_id": "0d4f6f36-2b6a-4c1e-9a6f-1f2d3c4b5a69", "status": "canceled" }
```

What cancelling does, as the code enforces it:

* The session's own messages do not send. Each one is checked against the session status at send time and skipped while the session is canceled.
* `register_for_webinar` and `set_webinar_attendance` refuse the session, because both would start registration and follow-up messages for a session that is not happening.
* A canceled series session keeps its date: the series will not rebuild that date, and builds a replacement session further out to keep its build-ahead count.

What cancelling does not do: nothing in the cancel path stops runs of sequences or workflows that registrants were already enrolled into. Read `get_webinar_plan` before cancelling so you and the human know what those runs still hold.

Restoring (`canceled` → `scheduled`) re-enables the session's messages. Messages whose moment passed while it was canceled are not re-sent.

## Deleting a session: delete\_webinar

`delete_webinar` removes a session only when it never gathered anything. It is refused, with the reason, when the session:

* belongs to a series (the series would rebuild the date) or is a series' template;
* has any registrations;
* has any message that is sending or sent.

In each of those cases cancel instead (`update_webinar` with `status: "canceled"`), which keeps who registered and what went out. For a series date that has not been built yet, use `skip_webinar_occurrence`.

```json theme={null}
{ "webinar_id": "5b1e0a2c-7d4f-4e8a-9c3b-2f6a1d8e4b70" }
```

Returns `{ "deleted": true }`. A successful delete also cancels the session's pending attendance pull and removes its unsent messages with it.

## Copying a session: clone\_webinar

`clone_webinar` copies a session to a new start time. The copy gets the source's title (or `title` if given), timezone, duration, join link, `automation_id`, and all of its messages, whatever state they reached on the source.

* Every copied message is a **draft**. Nothing a clone copies sends until someone schedules it.
* Messages timed from the session are re-anchored to the new start or end with the same offset. A message that was scheduled at a fixed time (no offset) is copied with no send time, for the human to date.
* Registrations are not copied; the clone starts empty.
* The platform link is not copied (two sessions on one platform id would share every registration and attendance webhook). Link the clone with `link_webinar_platform`.
* The clone is not part of any series, even when the source is.

```json theme={null}
{
  "webinar_id": "0d4f6f36-2b6a-4c1e-9a6f-1f2d3c4b5a69",
  "start_time": "2026-11-03T19:00:00-05:00",
  "title": "Pricing Teardown Live (November)",
  "new_webinar_id": "a7c2e9f1-3b5d-4f60-8e21-9d4c7b6a5f38"
}
```

Returns `{ "webinar": {...}, "created": true, "messages_cloned": 4 }`. A retry with the same `new_webinar_id` returns the clone already made with `created: false` and `messages_cloned: 0`.

To make the copied messages send, schedule each one with the broadcast tools after the human has reviewed them.

## Creating a recurring webinar: create\_webinar\_series

`create_webinar_series` creates a series and its first session in one call. The first session lands on the first cadence slot after now, becomes the series' **template**, and is bound into the series as its own occurrence. The series then builds sessions until `build_ahead_count` future sessions exist.

| Argument | Required | Notes |
| - | - | - |
| `name` | Yes | The series name, also the first session's title |
| `cadence.interval_unit` | Yes | `day` or `week` |
| `cadence.interval_count` | No | Every N days or weeks, 1 to 52. Default 1 |
| `cadence.weekday` | Weekly only | 0 = Sunday … 6 = Saturday. Required for `week`, refused for `day` |
| `cadence.time_of_day` | Yes | Local start time in the series timezone, `HH:mm` or `HH:mm:ss`, 24-hour |
| `timezone` | Yes | IANA name. The cadence runs in it, so 7pm stays 7pm across daylight-saving changes |
| `duration_minutes` | Yes | 1 to 1440 |
| `build_ahead_count` | No | 1 to 52 sessions kept built ahead. Default 3 |
| `join_url` | No | Valid URL |
| `series_id` | No | A v4 UUID you generate, the idempotency key |

A weekly Thursday 7pm webinar in New York, four sessions ahead:

```json theme={null}
{
  "series_id": "c4e8a1b7-2f3d-4a69-b5c0-7e1d9f2a6b83",
  "name": "Thursday Growth Clinic",
  "cadence": { "interval_unit": "week", "interval_count": 1, "weekday": 4, "time_of_day": "19:00" },
  "timezone": "America/New_York",
  "duration_minutes": 60,
  "build_ahead_count": 4,
  "join_url": "https://example.com/clinic"
}
```

Returns `{ "series": {...}, "template_webinar": {...}, "created": true, "sessions_built": 3 }`. `sessions_built` counts sessions built beyond the template. A retry with the same `series_id` returns the existing series with `created: false` and runs the build again (which only fills gaps).

Sessions built at creation copy an empty template. Each later session copies the template as it is at the moment it is built, so set the template up straight away:

1. Add the template session's messages (broadcast tools, `webinar_id` = `template_webinar.id`) and schedule them. Messages that are scheduled or already sent on the template are copied **armed**: they are scheduled on each new session automatically. Draft and stopped messages are copied as drafts. A message scheduled at a fixed time (no offset from the session) is copied as a draft with no time.
2. Link the template to its platform (`link_webinar_platform`).
3. Bind a sequence or workflow to the series (trigger with `series_id`) so every session's registrants enroll.
4. Fill the sessions that were built before the template was ready: they are separate copies and do not pick up later template edits to messages. Set each one up, or check each with `get_webinar_plan`.

Each built session copies the template's title, timezone, duration, join link, platform name and `automation_id`, with status `scheduled`.

The platform link is carried onto built sessions only where one link can serve every date:

| Template is linked to | Sessions the series builds |
| - | - |
| A **recurring** Zoom webinar | Linked to the same Zoom webinar. This holds in either order: linking the template also links the upcoming sessions already built. See the Zoom section |
| A WebinarJam webinar with one schedule per date | Each linked to the schedule on its own date. See the WebinarJam section |
| A one-off Zoom webinar, or a Demio event | Not linked. One platform event is one session; link each session to its own with `link_webinar_platform` |

## Reading series: list\_webinar\_series and get\_webinar\_series

`list_webinar_series` returns `{ "series": [...] }`, newest first, optionally filtered by `status` (`active`, `paused`, `archived`). Each series carries `id`, `name`, `status`, `timezone`, `cadence` (`interval_unit`, `interval_count`, `weekday`, `time_of_day` as `HH:mm:ss`), `cadence_summary` (for example `"Every Thursday · 7:00pm"`), `build_ahead_count`, `template_webinar_id` and `created_at`.

```json theme={null}
{ "status": "active" }
```

`get_webinar_series` takes `series_id` and returns `{ "series": {...} }` with the same fields plus:

* `sessions`: the sessions the series has built, template included, newest start first, at most 100.
* `skips`: `[{ "occurrence_date": "2026-11-26", "reason": "Thanksgiving" }]`.
* `upcoming_unbuilt`: the next six dates the series has not built yet, each `{ "occurrence_date": "2026-11-12", "start_time": "2026-11-13T00:00:00.000Z" }`. These are the dates `skip_webinar_occurrence` accepts.

```json theme={null}
{ "series_id": "c4e8a1b7-2f3d-4a69-b5c0-7e1d9f2a6b83" }
```

## Changing a series: update\_webinar\_series

`update_webinar_series` patches the repeat rule: `name`, `cadence` (any subset of its fields), `timezone`, `build_ahead_count` (0 to 52) and `status`.

```json theme={null}
{
  "series_id": "c4e8a1b7-2f3d-4a69-b5c0-7e1d9f2a6b83",
  "cadence": { "weekday": 2, "time_of_day": "18:30" },
  "build_ahead_count": 6
}
```

* The rule applies to sessions not built yet. Sessions that already exist keep their times; move one with `update_webinar` (a reschedule).
* Switching `interval_unit` to `day` drops the weekday. Switching to `week` needs a `weekday` unless the series already has one.
* `status: "paused"` or `"archived"` stops the series building and clears its pending check. `"active"` resumes it.
* What each new session copies comes from the template session, not from the series. Edit the template to change future sessions; `update_webinar_series` has no title, duration or message fields.

Every call ends by running the build immediately and returns `{ "series": {...}, "materialized": {...} }` (see the next section for the `materialized` shape). Raising `build_ahead_count` therefore builds the extra sessions in the same call.

## Occurrences: skip\_webinar\_occurrence, unskip\_webinar\_occurrence and materialize\_webinar\_series

**`skip_webinar_occurrence`** tells a series not to build one upcoming date. Take the date from `get_webinar_series` `upcoming_unbuilt`.

```json theme={null}
{
  "series_id": "c4e8a1b7-2f3d-4a69-b5c0-7e1d9f2a6b83",
  "occurrence_date": "2026-11-26",
  "reason": "Thanksgiving"
}
```

Returns `{ "series_id": "...", "occurrence_date": "2026-11-26", "created": true }`; `created: false` when the date was already skipped. It is refused when:

* `occurrence_date` is not `yyyy-MM-dd`;
* a session is already built for that date. The error names the session; cancel it with `update_webinar` (`status: "canceled"`) instead, which also holds the date;
* the date is not one the cadence would build (wrong weekday, in the past, or more than 104 slots out). The error lists the next unbuilt dates.

**`unskip_webinar_occurrence`** puts a skipped date back and immediately runs the build, so a date inside the build-ahead horizon gets its session at once. It returns `{ "series_id", "occurrence_date", "materialized": {...} }` and is refused with not-found when the date is not skipped.

```json theme={null}
{ "series_id": "c4e8a1b7-2f3d-4a69-b5c0-7e1d9f2a6b83", "occurrence_date": "2026-11-26" }
```

**`materialize_webinar_series`** builds any sessions the series is short of right now, instead of waiting for its next check (which runs about five minutes after the next session starts, or hourly when it has none ahead). It only fills gaps, so it is safe to repeat.

```json theme={null}
{ "series_id": "c4e8a1b7-2f3d-4a69-b5c0-7e1d9f2a6b83" }
```

It returns:

```json theme={null}
{ "created": 1, "skipped": 0, "status": "active", "next_run_at": "2026-10-10T00:05:00.000Z" }
```

`created` is sessions built on this run. `skipped` is dates another concurrent run had already built. `next_run_at` is `null` when the series is paused or archived; such a series builds nothing.

How the build counts: it keeps `build_ahead_count` future sessions that are not canceled. A date is taken when it has a session (in any status, canceled included) or is skipped. So cancelling a future series session makes the series build one more date at the end of its horizon, and skipping a date makes it step over that date.

## Registrants: list\_webinar\_registrants

`list_webinar_registrants` lists who registered for a session and what the session knows about each person.

| Argument | Notes |
| - | - |
| `webinar_id` | Required |
| `segment` | Optional: `registered`, `attended`, `no_show`, `left_early`, `clicked_offer`, `purchased`, `refunded` |
| `limit` | 1 to 500, default 100 |
| `offset` | Default 0 |

```json theme={null}
{ "webinar_id": "0d4f6f36-2b6a-4c1e-9a6f-1f2d3c4b5a69", "segment": "no_show", "limit": 200 }
```

The response:

* `provider`: the platform the session runs on. Watch depth exists only where the platform reports it.
* `segment_counts`: the size of every segment across all registrants, whatever `segment` and paging you asked for.
* `registrants`: each with `person_id`, `name`, `email`, `registered_at`, `attended`, `attendance_resolved`, `watched_minutes`, `watch_pct`, `left_early`, `clicked_offer_count`, `purchase_amount`, `purchase_attributed`, `refunded`, and `segments` (every segment the person is in).
* `total`: registrants matching the filter across all pages, plus the `limit` and `offset` used.

`attended: false` with `attendance_resolved: false` means not known yet, not a no-show. The `no_show` segment only holds resolved non-attendees.

## Registering someone: register\_for\_webinar

<Warning>
  `register_for_webinar` sends real messages. It enrolls the person into every live sequence and workflow bound to the session (confirmations, reminders) and pushes them to the linked platform. Get the human's go-ahead before registering real people, and say which session and which person.
</Warning>

It takes a session and an existing person. Resolve the person first: `search_people` for someone already in Flywheel, or `identify_person` to find-or-create from an email or phone ([People over MCP](/mcp/people)).

```json theme={null}
{
  "webinar_id": "0d4f6f36-2b6a-4c1e-9a6f-1f2d3c4b5a69",
  "person_id": "9e2b4c71-0a5f-4d38-b6e1-3c7f8a2d5e94"
}
```

Returns:

```json theme={null}
{
  "registration_id": "7c1b2a3d-5e6f-4a8b-9c0d-1e2f3a4b5c6d",
  "person_id": "9e2b4c71-0a5f-4d38-b6e1-3c7f8a2d5e94",
  "already_registered": false,
  "join_url": "https://us02web.zoom.us/w/81234567890?tk=...",
  "join_url_personal": true
}
```

`join_url` is the link this person joins with, the same one their messages carry. It is their own link when the linked platform issued one (`join_url_personal: true`), otherwise the session's shared `join_url` (`join_url_personal: false`), and `null` when the session has neither. Only hand someone a link as "your join link" when `join_url_personal` is true: on a Zoom webinar that requires registration, the shared link opens Zoom's registration form, not the room.

What one call does, in order:

1. Creates the registration (one per person and session).
2. Pushes the registrant to the linked platform, so their personal join link exists before any reminder uses it: Demio and WebinarJam when linked, and Zoom when linked. A platform failure never fails the registration; it is logged. Zoom pushes are never retried, because Zoom allows only three adds per person, webinar and day.
3. Enrolls the person into every live sequence and workflow whose `webinar_registration` trigger matches this session, and records a `$fw_webinar_registration` event on their timeline.

Registering someone twice is safe: the second call writes nothing, sends nothing and returns `already_registered: true` with the same `join_url`. A canceled session refuses registrations.

A platform push that fails or is skipped still returns a registration; its `join_url` is the session's shared link with `join_url_personal: false`.

To see exactly what a registration made now will send, call `get_webinar_plan` with `entry_times` set to now before registering.

## Recording attendance: set\_webinar\_attendance

<Warning>
  `set_webinar_attendance` arms the session's outcome follow-ups (the attended and no-show sequences), so it sends real messages after the session ends. Confirm with the human first.
</Warning>

It records whether a registered person attended. `attended: false` marks a no-show.

```json theme={null}
{
  "webinar_id": "0d4f6f36-2b6a-4c1e-9a6f-1f2d3c4b5a69",
  "person_id": "9e2b4c71-0a5f-4d38-b6e1-3c7f8a2d5e94",
  "attended": true
}
```

Returns `{ "person_id": "...", "attended": true, "attendance_resolved_at": "2026-10-21T00:12:09.000Z" }`.

* The person must already be registered; otherwise it fails with "register them first".
* A canceled session refuses attendance.
* Marking someone attended again keeps the first time they were marked present.
* Marking by hand reports no watch depth, so `watch_pct` and `left_early` stay unknown for that person.
* Outcome sequences enroll through a sweep that never runs before the session's end, even if you mark attendance during the live session.

Linked platforms report attendance on their own after the session ends (Demio, WebinarJam and Zoom). Use this tool for sessions without a platform link, or to correct a record.

## Sequences and workflows bound to a session

A session's messages come from two places, and `get_webinar_plan` shows both:

1. **The session's own messages**: broadcasts with `audience: "webinar"` and this `webinar_id`, timed with `webinar_offset_minutes` from `webinar_anchor` `start` or `end` (positive is before the anchor, negative after). They go to the session's `registered`, `attended` or `no_show` audience at one fixed moment. Manage them with the broadcast tools ([Broadcasts over MCP](/mcp/broadcasts)).
2. **Bound sequences and workflows**: automations with one of two triggers:
   * `webinar_registration`: fires once per new registration. Steps count from the moment the person registered, or from the session start for anchored waits.
   * `webinar_attendance`: fires once per registrant after the session has ended, for those whose `outcome` matches: `attended`, `no_show`, `left_early`, `watched_min_pct` (with `min_watch_pct`), `clicked_offer`, `purchased` or `refunded`. Steps count from the session end.

Each trigger's params bind it with `webinar_id` (one session), `series_id` (every session of a series) or neither (any webinar in the organization). A trigger carrying both only fires for that session inside that series. Only a **live, published** automation enrolls anyone; a draft version enrolls nobody.

```json theme={null}
{
  "params_type": "webinar_registration",
  "params_version": 1,
  "webinar_id": null,
  "series_id": "c4e8a1b7-2f3d-4a69-b5c0-7e1d9f2a6b83"
}
```

The `automation_id` on a session is a label only. Setting it with `create_webinar` or `update_webinar` binds nothing and enrolls nobody.

To build or change a bound workflow over MCP, follow [Workflows over MCP](/mcp/workflows): `get_node_types` for the trigger shapes, `get_vocabulary` for valid webinar ids, then create, publish and set it live. `set_workflow_status` live starts real sends, so confirm with the human first. A series binding is the right choice for a recurring webinar: every session the series builds is covered without touching the workflow again.

## Reading the send plan: get\_webinar\_plan

`get_webinar_plan` shows everything a session sends and when, and resolves it into the exact, timestamped list someone receives if they register at a given moment. Call it before and after any change to a session's schedule, messages or bound sequences, and show the human the result before anything goes live.

```json theme={null}
{
  "webinar_id": "0d4f6f36-2b6a-4c1e-9a6f-1f2d3c4b5a69",
  "entry_times": ["2026-10-08T15:00:00Z", "2026-10-20T21:30:00Z"]
}
```

`entry_times` is up to 12 ISO-8601 registration moments. Leave it out and four are sampled: ten days before the start, three days before, two hours before, and one hour after the end.

The response is `{ "spec": {...}, "previews": [...] }`.

### The plan spec (get\_webinar\_plan spec)

`spec` is the plan before it is resolved for anyone:

* `webinar_id`, `title`, `status`, `start_time`, `end_time` (start plus duration, or the start when there is no duration), `duration_minutes`.
* `broadcasts`: the session's own messages, each with `broadcast_id`, `channel` (`email` or `sms`), `label` (subject or the start of the text), `status`, `anchor` (`start` or `end`), `offset_minutes` (signed, positive is before the anchor, `null` when not timed from the session) and `scheduled_at`.
* `sequences`: one entry per bound sequence, live **and** paused, with `automation_id`, `name`, `status`, `role` (`registration` or the outcome it fires on), `binding` (`webinar`, `series` or `any`), `stops_at_live` (it carries the "webinar has started" exit) and its `steps`.
* `workflows`: bound workflows that are live, with `automation_id`, `name`, `binding`, `step_count` and `stops_at_live`.
* `workflow_steps`: the flattened steps of everything live that enrolls people, each with `action_id`, `automation_id`, `channel`, `label`, `timing_type` (`immediate`, `relative` or `anchored`), `relative_seconds`, `anchor_offset_seconds`, `branch`, `skippable` and `counts_from` (`entry` or `session_end`).

Paused sequences appear in `spec.sequences` so you can see them, but their steps never reach `workflow_steps` or the previews: a paused sequence sends nothing.

### Resolved previews (get\_webinar\_plan previews)

Each entry in `previews` resolves the plan for one registration moment:

* `entry_time` and `entry_label` (for example `"2 hours out"` or `"after it ended"`).
* `entries`: every message in time order, each with `kind` (`broadcast`, `workflow_step` or `live_wall`), `lane` (`clock` for fixed-moment items, `registration` for steps counted from sign-up, `outcome` for post-session steps), `channel`, `label`, `at`, `skipped`, `skip_reason` and `skip_sentence`. The `live_wall` entry marks the session start.
* `density`: `total`, `delivered`, `email`, `sms`, `other`, `skipped` and the delivered span. `density_sentence` says it in words, for example `"6 messages over 3 days · 5 email, 1 text"`.
* `collisions`: pairs of delivered messages landing within 15 minutes of each other for this registrant, each with a `sentence` to show the human.

Skip reasons:

* `moment_passed`: a step whose moment was already gone when they registered. The runtime skips it rather than sending it late.
* `stopped_at_live`: a registration-timed step landing after the start, in an automation that stops when the webinar starts.
* `before_registration`: a session message that went out before they registered. They were not in its audience.

Reading it well:

* Compare the early and late entry times. A ladder of reminders that looks fine ten days out can stack three messages into one hour for someone who registers on the day; `collisions` names exactly that.
* Check `spec.broadcasts[].status`. Previews place every session message by its timing, drafts included; a draft or stopped message only sends once it is scheduled.
* An entry with `at: null` is a session message with no send time yet. It sorts last.
* An empty plan (`density_sentence` `"Nothing lands for this registrant."`) on a session with registrants usually means no live sequence is bound to it.

## Revenue: get\_webinar\_revenue and get\_webinar\_series\_revenue

`get_webinar_revenue` reports what one session earned:

```json theme={null}
{ "webinar_id": "0d4f6f36-2b6a-4c1e-9a6f-1f2d3c4b5a69" }
```

It returns `registered_count`, `attended_count`, `click_count`, `unique_clicker_count`, `buyer_count`, and revenue blocks. Each block has `purchase_count`, `gross_revenue`, `refunded_revenue`, `net_revenue` and `new_net_revenue` (net excluding renewals). The blocks:

* `total`: everything in the session's scope. It splits into three disjoint parts:
* `broadcast_attributed`: attributed to one of this session's own messages.
* `message_attributed`: attributed to another send, such as a workflow step.
* `unattributed`: real revenue with no attributing click.
* `attributed`: `broadcast_attributed` plus `message_attributed`.

`broadcasts` breaks it down per session message: `broadcast_id`, `subject`, `status`, `scheduled_at`, `sent_count`, `click_count`, `unique_clicker_count`, `buyer_count` and its `attributed` block.

`get_webinar_series_revenue` reports a series:

```json theme={null}
{ "series_id": "c4e8a1b7-2f3d-4a69-b5c0-7e1d9f2a6b83", "session_limit": 12 }
```

It returns the same counts and blocks computed over the distinct purchases across the series (one buyer registered for two sessions is one sale), `session_count`, and `sessions`: a per-session rollup of the most recent `session_limit` sessions (1 to 100, default 50). The per-session figures do not add up to the series total by design. Report the series figures as the series result and the sessions as a breakdown.

<Warning>
  Both tools return `reconciles` and `reconciliation_issues`. When `reconciles` is `false`, the figures do not add up: tell the human that, rather than reporting the numbers.
</Warning>

## Linking a session to its platform: link\_webinar\_platform

`link_webinar_platform` connects a session to the platform it runs on. From then on, registrations are pushed there (so each person gets their personal join link) and attendance comes back after the session ends. The platform must be connected for the organization under Settings → Integrations; Demio and Zoom refuse the link otherwise.

| Argument | Notes |
| - | - |
| `webinar_id` | Required |
| `platform` | `demio`, `webinarjam` or `zoom` |
| `event_id` | The platform's event or webinar id |
| `date_id` | Demio and WebinarJam only: the session or schedule inside the event. Ignored for Zoom |
| `sync_details` | Demio only. Default `true` |

It returns `{ "webinar": {...}, "topic": "...", "recurring": null }`. `topic` is the platform's own name for what was linked (Demio and Zoom), so you can confirm with the human it is the right one. `recurring` is set for Zoom only.

Linking to a different platform than before cancels the old platform's pending attendance pull. Re-linking replaces the stored platform snapshot, so a link to a different event never reuses the old one's details.

### Demio (link\_webinar\_platform platform demio)

`event_id` is the Demio event; `date_id` is the session inside it. Leave `date_id` out to use the event's own timestamp.

```json theme={null}
{
  "webinar_id": "0d4f6f36-2b6a-4c1e-9a6f-1f2d3c4b5a69",
  "platform": "demio",
  "event_id": "1234567",
  "date_id": "7654321"
}
```

By default (`sync_details` true) the session's title, start time (the chosen date's, or the event's), duration, timezone and join link (Demio's registration URL) are overwritten from Demio. When that moves the start or duration, everything timed off the session is re-anchored exactly as a reschedule does. Pass `"sync_details": false` to keep the session's own details. The event id must load from the connected Demio account, or the link is refused.

### WebinarJam (link\_webinar\_platform platform webinarjam)

`event_id` is the WebinarJam webinar id; `date_id` is its schedule id, which says which of the webinar's dates this session is.

```json theme={null}
{
  "webinar_id": "0d4f6f36-2b6a-4c1e-9a6f-1f2d3c4b5a69",
  "platform": "webinarjam",
  "event_id": "42"
}
```

* Leave `date_id` out on a webinar with several schedules and the schedule running at this session's start (within 60 minutes) is picked, when exactly one matches and the WebinarJam API key is connected. No match, or no API access, leaves it blank. On a single-schedule webinar the link is webinar-level.
* Two sessions cannot be linked to the same WebinarJam webinar and schedule; the second link is refused.
* WebinarJam reports to Flywheel through custom webhooks, routed by the ids this link records. The human must also add three automation rules on the WebinarJam side (registration, attended live, missed live); linking does not create them.
* Without a connected WebinarJam API key the link is still recorded, but registrations cannot be pushed to WebinarJam.

<Warning>
  **WebinarJam recurring schedules do not work with Flywheel series.** A recurring WebinarJam webinar keeps one schedule id for every date, so Flywheel cannot tell its dates apart: every registration WebinarJam sends routes to the one session holding that link, and once that session has ended, registrations arriving for later dates are refused rather than landed on the wrong session. The supported setup is **one WebinarJam schedule per date**. With that, a series whose template is linked to the WebinarJam webinar links each future session it builds to the schedule on that session's date, on every build run (this needs the WebinarJam API key connected). A date with no matching schedule (or one already taken) stays unlinked until a schedule is added in WebinarJam; the next run picks it up.
</Warning>

### Zoom (link\_webinar\_platform platform zoom)

`event_id` is the Zoom webinar id as Zoom shows it. Spaces and dashes are fine (`"812 3456 7890"` or `"81234567890"`); it must be 9 to 12 digits.

```json theme={null}
{
  "webinar_id": "0d4f6f36-2b6a-4c1e-9a6f-1f2d3c4b5a69",
  "platform": "zoom",
  "event_id": "812 3456 7890"
}
```

* Zoom must be connected, and the webinar must exist on the connected Zoom account. A meeting id, a typo or a webinar on another account is refused with "No webinar … on the connected Zoom account".
* A one-off Zoom webinar can back only one session. A recurring Zoom webinar (`recurring: true` in the response) can back several sessions. Attendance and the "webinar ended" event go to the session whose scheduled time the running falls in, or nearest to within 12 hours; a running that far from every session is not recorded.
* **A series on a recurring Zoom webinar needs one link.** Link the series' template session and every upcoming session of the series is linked to the same Zoom webinar: the ones already built at that moment, and each one the series builds afterwards. Creating the series first and linking the template after works the same as the other order. Moving the template to another recurring Zoom webinar moves those sessions with it. A session of the series left unlinked is linked again on the next build run, so to take one date off the webinar, link that session elsewhere or cancel it. A session you linked to a different webinar by hand is left alone.
* For registrants to get a personal link on a recurring webinar, set it up in Zoom as: registration required, and "attendees register once and can attend any of the occurrences". Someone registered for more than one date keeps one Zoom registration and one link. A recurring webinar that registers people per date is not pushed to.
* Errors say what to fix: reconnect Zoom, the app needs installing by an account admin, or Zoom did not answer (try again).
* The attendance pull is armed for the session's end; Zoom's own "webinar ended" event brings it forward.

### Removing a platform link: unlink\_webinar\_platform

`unlink_webinar_platform` disconnects a session from Demio, WebinarJam or Zoom. New registrations stop being pushed to the platform, inbound platform events stop routing to the session, and the pending attendance pull is cancelled. Registrations and attendance already recorded stay.

```json theme={null}
{ "webinar_id": "0d4f6f36-2b6a-4c1e-9a6f-1f2d3c4b5a69" }
```

Returns `{ "webinar": {...} }` with `platform: null`.

Unlinking a series' template session from Zoom also unlinks the series' upcoming sessions that shared that Zoom webinar. Sessions that have ended keep their link and their attendance.

## Common webinar tasks

### What webinars are coming up, and how are they filling?

1. `list_webinars` with `starts_after` = now, `status` `scheduled`, `order` `asc`.
2. `get_webinar` on each session the human cares about for `registrations` and `messages` counts.

### Set up a one-off webinar end to end

<Steps>
  <Step title="Create the session">
    `create_webinar` with `title`, `start_time` (with offset), `timezone`, `duration_minutes` and a fresh `webinar_id`.
  </Step>

  <Step title="Link the platform">
    `link_webinar_platform`. Read `topic` back to the human to confirm it is the right event.
  </Step>

  <Step title="Add the session's messages">
    `create_broadcast` with `audience: "webinar"`, the `webinar_id`, and `webinar_offset_minutes` / `webinar_anchor`, then plan and schedule with the human's go-ahead. See [Broadcasts over MCP](/mcp/broadcasts).
  </Step>

  <Step title="Bind a registration sequence">
    A live workflow with a `webinar_registration` trigger bound to this `webinar_id` (or reuse one bound to any webinar). See [Workflows over MCP](/mcp/workflows).
  </Step>

  <Step title="Check the plan">
    `get_webinar_plan` with no `entry_times`. Show the human the `density_sentence` and any `collisions` for each sample.
  </Step>
</Steps>

### Set up a weekly webinar

1. `create_webinar_series` with the cadence, timezone, duration and a fresh `series_id`.
2. Set up `template_webinar` from the response: messages (scheduled, timed from the session), platform link. A recurring Zoom webinar linked here is linked on every upcoming session of the series too.
3. Bind sequences with `series_id` on the trigger, so every built session's registrants enroll.
4. `get_webinar_series` and `get_webinar_plan` on each session already built; those were copied before the template was ready.

### Move a session to a new time

1. `get_webinar_plan` on the session and `get_webinar` for its registration count.
2. Tell the human what will move, and get their go-ahead.
3. `update_webinar` with the new `start_time`. Report `schedule_effects` back.
4. If the session is linked to Demio, WebinarJam or Zoom, the time must also change on the platform; Flywheel does not change it there.
5. `get_webinar_plan` again to confirm the new timings.

### Skip a holiday on a series

1. `get_webinar_series` and find the date.
2. If it is in `upcoming_unbuilt`: `skip_webinar_occurrence` with that `occurrence_date` and a `reason`.
3. If a session is already built for it: confirm with the human, then `update_webinar` that session with `status: "canceled"`.

### Register someone the human named

1. `search_people` (or `identify_person` with their email) to get `person_id`.
2. `get_webinar_plan` with `entry_times` = now, so the human sees what this person will receive.
3. With their go-ahead, `register_for_webinar`. Report `already_registered`.

### Follow up after a session

1. `list_webinar_registrants` with `segment` `attended`, `no_show`, `clicked_offer` or `purchased`.
2. `get_webinar_revenue`; check `reconciles` before quoting numbers.
3. Corrections to attendance: `set_webinar_attendance`, with the human's go-ahead.

## Webinar mistakes to avoid

* **Registering or marking attendance without asking.** `register_for_webinar` and `set_webinar_attendance` send real messages. Confirm first, every time, and name the session and person.
* **Using `automation_id` to attach a sequence.** It is a label. Enrollment comes only from live automations with a `webinar_registration` or `webinar_attendance` trigger.
* **Deleting what should be cancelled.** `delete_webinar` refuses anything with registrations, sent messages or a series. Cancel instead; it keeps the history.
* **Assuming cancel stops everything.** Cancel stops the session's own messages and refuses new registrations and attendance. It does not stop sequence runs already in progress.
* **Rescheduling and assuming the platform follows.** `update_webinar` moves Flywheel's clock, not Demio's, WebinarJam's or Zoom's. Change the time there too.
* **Expecting a series edit to move built sessions.** `update_webinar_series` only shapes sessions not built yet. Reschedule existing ones with `update_webinar`.
* **Editing the series instead of the template.** Title, duration, join link and messages of future sessions come from the template session. Edit that session.
* **Leaving template messages as drafts.** A series copies draft messages as drafts on every session, so they never send. Schedule them on the template.
* **Skipping a built date.** `skip_webinar_occurrence` refuses a date that already has a session. Cancel that session instead.
* **Cloning and expecting it to send.** `clone_webinar` copies messages as drafts and drops the platform link. Schedule the messages and link the clone.
* **Linking a recurring WebinarJam webinar to a series.** One schedule id covers every date, so Flywheel cannot tell them apart. Use one WebinarJam schedule per date.
* **Linking a Zoom meeting id.** Only a Zoom webinar on the connected account links.
* **Reading a preview as "this sends".** Previews include draft session messages. Check `spec.broadcasts[].status`.
* **Quoting revenue that does not reconcile.** When `reconciles` is `false`, say the numbers do not add up. And never sum `sessions` to get a series total.
* **Inventing ids.** Read ids from list and get tools; only idempotency keys are generated, as fresh v4 UUIDs.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.