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

# Broadcasts API

> Draft, preview and schedule email and text broadcasts — every one planned first, and scheduled at least an hour ahead

## Overview

A broadcast is one email (or, for a webinar session, one text) sent once to an audience: everyone with an email address, a saved segment, or a webinar session's registrants.

Broadcasts are **scheduled** through the API, never sent immediately, and committing one takes two calls:

1. **Plan** — `POST /v1/broadcasts/{broadcast_id}/plan` with a `send_at` shows exactly what would happen: how many people it reaches, when it goes out, who it is from, the body as one real recipient receives it, and every check that would stop or surprise the send. When nothing blocks, it returns a `plan_token`.
2. **Schedule** — `POST /v1/broadcasts/{broadcast_id}/schedule` with that `plan_token` commits the plan. If anything the plan covered has changed since (content, variables, sender, audience, a segment's filters, timing), the plan is over an hour old, or the send time is now less than an hour away, it is refused and you plan again.

A broadcast always goes out **at least an hour after it is scheduled**, and until then it can be pulled back — with `POST /v1/broadcasts/{broadcast_id}/unschedule`, or **Unschedule** on the broadcast in the dashboard. Everything before `/schedule` is safe to call freely; drafts and plans reach no one.

<Tip>
  The same operations are MCP tools on `https://api.flywheel.cx/mcp`: `create_broadcast`, `update_broadcast`, `list_broadcast_variables`, `plan_broadcast`, `schedule_broadcast`, `unschedule_broadcast`, `get_broadcast_stats` and `list_senders`.
</Tip>

## Authentication

```
Authorization: YOUR_API_KEY
Auth-Type: api
```

API keys are managed in the dashboard under **Settings → API Keys**. Every broadcast, segment, sender and webinar is resolved inside the organization the key belongs to.

## Variables

Broadcasts use Flywheel's variable system — the same one the dashboard editor saves and workflow messages use. The body holds `{{var_<id>}}` placeholders, and `variables` says what fills each one:

```json theme={null}
{
  "html": "<p>Hi {{var_first}},</p><p>Your plan: {{var_plan}}.</p>",
  "variables": {
    "var_first": { "variable": "{{context.root_context.org_user.first_name}}", "fallback": "there" },
    "var_plan": { "variable": "{{context.root_context.org_user.custom_properties.plan_name}}", "fallback": "your plan" }
  }
}
```

| Field | Default | Description |
| - | - | - |
| `variable` | required | What to read — a path from `GET /v1/broadcasts/variables` |
| `fallback` | `""` | Used when the person has no value |
| `template` | `"{{var}}"` | Text around the value, e.g. `"your {{var}} plan"` |
| `date_format` | none | For dates: a date-fns pattern such as `"MMM d"`, or `relative-short` / `relative-today` |
| `text_format` | set for name fields | `"name"` repairs the casing of self-typed names |

`GET /v1/broadcasts/variables` lists what variables can read: the recipient's fields, your organization's custom properties (by key), and — on a broadcast tied to a webinar — the session: `webinar_title`, `webinar_start_time`, `join_url` (the recipient's personal link) and add-to-calendar links.

Where placeholders can go, matching what the dashboard editor keeps when someone edits the broadcast there:

* **Email text:** `{{var_<id>}}`.
* **A link:** the whole `href` is a `{{link_var_<id>}}` whose `variable` is exactly one path — `<a href="{{link_var_join}}">Join</a>`. A linked image is an `<a href>` around the `<img>`.
* **Texts:** `{{var_<id>}}` anywhere; texts are plain, so put a URL variable in the text.

Placeholders without a definition, variables whose path does not resolve, template syntax that is not a variable, and variables in the subject or preview text (which are sent as written) are refused rather than sent blank. So are scripts, forms, inline event handlers and `javascript:` links. Variables the body does not use are dropped.

## Create a Broadcast

`POST /v1/broadcasts`

```javascript theme={null}
const response = await fetch('https://api.flywheel.cx/v1/broadcasts', {
  method: 'POST',
  headers: { 'Authorization': 'YOUR_API_KEY', 'Auth-Type': 'api', 'Content-Type': 'application/json' },
  body: JSON.stringify({
    audience: 'segment',
    segment_id: '7c0e5a3e-…',
    subject: 'Your October product update',
    preview_text: 'Three things we shipped this month',
    sender_id: 'b1f2…', // from GET /v1/senders
    html: '<p>Hi {{var_first}},</p><p>Here is what shipped…</p>',
    variables: { var_first: { variable: '{{context.root_context.org_user.first_name}}', fallback: 'there' } }
  })
});
const { broadcast, created } = await response.json();
```

| Field | Type | Required | Description |
| - | - | - | - |
| `broadcast_id` | string | No | Your own v4 UUID. Makes the call safe to retry: the same id returns the draft already created (`created: false`) |
| `channel` | string | No | `email` (default) or `sms`. Text broadcasts go to a webinar session's registrants only |
| `audience` | string | Yes | `everyone` (every person with an email address), `segment`, or `webinar` |
| `segment_id` | string | With `segment` | A segment of your organization |
| `webinar_id` | string | With `webinar` | A webinar session of your organization |
| `webinar_audience` | string | No | Webinar only: `registered` (default), `attended` or `no_show` |
| `webinar_offset_minutes` | number | No | Webinar only: time the broadcast from the session — minutes before the anchor, negative for after. `60` = an hour before the start |
| `webinar_anchor` | string | No | Webinar only: `start` (default) or `end` of the session |
| `subject` | string | To schedule | Email subject, sent exactly as written |
| `preview_text` | string | No | Email inbox preview line |
| `html` | string | To schedule | Email body with `{{var_…}}` placeholders |
| `message_body` | string | To schedule | Text broadcasts: the message, with `{{var_…}}` placeholders |
| `variables` | object | With placeholders | What fills each placeholder — see [Variables](#variables) |
| `sender_id` | string | To schedule | Email: who it is from, from `GET /v1/senders` |

Keep the email HTML to what the dashboard editor can reopen — `p`, `h1`–`h3`, `strong`, `em`, `a`, `ul`/`ol`/`li`, `img`, `hr`, `br` — since your organization's email layout (logo, footer, unsubscribe link) wraps it at send. A broadcast created here appears in the dashboard's Broadcasts list and opens in its editor like any other.

## Plan

`POST /v1/broadcasts/{broadcast_id}/plan`

```javascript theme={null}
const plan = await fetch(`https://api.flywheel.cx/v1/broadcasts/${id}/plan`, {
  method: 'POST',
  headers: { 'Authorization': 'YOUR_API_KEY', 'Auth-Type': 'api', 'Content-Type': 'application/json' },
  body: JSON.stringify({ send_at: '2026-10-06T15:00:00-04:00' }) // at least an hour from now
}).then((r) => r.json());
```

```json theme={null}
{
  "broadcast_id": "…",
  "ready": true,
  "plan_token": "bpt1.1790000000.at1790622000.4c1d…",
  "expires_at": "2026-10-01T13:00:00.000Z",
  "channel": "email",
  "delivery": { "send_at": "2026-10-06T19:00:00.000Z", "paced_over_hours": 3 },
  "audience": { "kind": "segment", "segment_name": "Active customers", "recipients": 1284, "note": "…" },
  "sender": { "id": "…", "from": "Jaen <jaen@acme.com>", "reply_to": "hi@acme.com" },
  "preview": { "person_id": "…", "subject": "Your October product update", "body": "<p>Hi Ada,</p>…" },
  "checks": []
}
```

* `send_at` must be at least an hour from now (and at most a year). A webinar broadcast timed from its session (`webinar_offset_minutes`) plans for that time instead and takes no `send_at`.
* `recipients` is counted the way the sender counts, now; the audience is resolved again when it sends, and people who opted out, bounced or complained are skipped then.
* `delivery.paced_over_hours`: sends to a list are spread over three hours from `send_at`; webinar broadcasts go out at once.
* `checks` with `level: "error"` block scheduling (no `plan_token` is issued) — for example `send_at_required`, `send_at_too_soon`, `subject_missing`, `sender_missing`, `sender_domain_unverified`, `variable_missing`, `webinar_canceled`, `allowance_used_up`. `warning` checks don't block but are worth reading: `everyone` (the broadcast goes to your whole list), `no_recipients_yet`, `allowance_short`, `variable_unresolvable`, `gmail_clipping`, `sent_before`.

Planning writes nothing.

## Schedule

`POST /v1/broadcasts/{broadcast_id}/schedule`

```json theme={null}
{ "plan_token": "bpt1.1790000000.at1790622000.4c1d…" }
```

```json theme={null}
{
  "broadcast": { "id": "…", "status": "scheduled", "timing": { "send_at": "2026-10-06T19:00:00.000Z", … }, … },
  "delivery": { "send_at": "2026-10-06T19:00:00.000Z", "paced_over_hours": 3 },
  "message": "Scheduled for 2026-10-06T19:00:00.000Z, spread over 3 hours from then. Until then it can be pulled back: unschedule_broadcast, or Unschedule on the broadcast in the dashboard."
}
```

It returns `409` if the broadcast changed since it was planned or the plan expired — plan again — and `400` if something now blocks it, including a send time that is less than an hour away.

## Unschedule

`POST /v1/broadcasts/{broadcast_id}/unschedule`

Takes a scheduled broadcast back to `draft`, up to the moment it starts sending. Refused once sending has begun.

## Read, Edit and Delete

| Endpoint | |
| - | - |
| `GET /v1/broadcasts` | List, newest first. Filters: `status` (`draft`, `scheduled`, `sending`, `sent`, `stopped`, `error`), `channel`, `webinar_id`; paging with `limit` (max 200) and `offset` |
| `GET /v1/broadcasts/{broadcast_id}` | One broadcast, with its body and variables |
| `PATCH /v1/broadcasts/{broadcast_id}` | Change a draft. Omitted fields stay; `null` clears `preview_text`, `sender_id` or `webinar_offset_minutes`. A new `html` / `message_body` replaces the whole body; `variables` replaces the whole map; the body and its variables are checked together. A scheduled broadcast has to be unscheduled first |
| `DELETE /v1/broadcasts/{broadcast_id}` | Delete a broadcast nobody has received any of |
| `GET /v1/broadcasts/variables` | What a body's variables can read |
| `GET /v1/broadcasts/{broadcast_id}/stats` | Sent / opened / clicked totals and per day, where every recipient ended up, and why skipped ones were skipped |
| `GET /v1/senders` | The email senders a broadcast can use (`ready` when the sending domain is verified), and whether your organization has an SMS number for text broadcasts |

<Note>
  Any change to a broadcast invalidates an earlier plan. Plan again after editing, and review it before scheduling.
</Note>
