Skip to main content

What a broadcast is

A broadcast is one email (or, for a webinar session, one text) sent once to an audience. It is different from a workflow (see /mcp/workflows), which reacts to events per person over time: a broadcast is a single send to a whole audience at one moment. Every broadcast tool on the Flywheel MCP server (https://api.flywheel.cx/mcp): Everything before schedule_broadcast is safe to call freely: drafts and plans reach no one. schedule_broadcast is the only tool that commits a send, and it only accepts a plan_token from plan_broadcast.
Broadcasts are only scheduled through the MCP, never sent immediately, and always at least an hour ahead. Show the plan from plan_broadcast to the person you work for and get their go-ahead before calling schedule_broadcast: it reaches real people.
A connection only sees the tools its permissions allow. If a tool is called without its permission, the result is an error naming the missing permission (for example broadcasts.send); an org owner has to reconnect with that access. Arguments that do not match a tool’s schema are rejected as invalid arguments. Every other failure (a refused body, an unknown id, a refused schedule) comes back as a tool result marked as an error, with a message saying what to fix. The REST equivalents of these tools are documented in /api-reference/broadcasts.

Broadcast lifecycle and statuses

A broadcast’s status is one of draft, scheduled, sending, sent, stopped or error.
  • draft: created with create_broadcast, or returned to draft by unschedule_broadcast or by editing a stopped / error broadcast with update_broadcast.
  • scheduled: committed with schedule_broadcast (or scheduled in the dashboard). update_broadcast and delete_broadcast refuse it until unschedule_broadcast takes it back to draft.
  • sending / sent: it has gone out. It can no longer be edited, re-planned or deleted. To send similar content again, create a new broadcast.
  • stopped: an earlier send was stopped (the email allowance ran out, or a pre-webinar message’s moment passed once the session started). error: an earlier send failed to enqueue. Both can be edited, planned and scheduled again; anyone who already received it is skipped as a duplicate.
The tools that write (update_broadcast, schedule_broadcast, delete_broadcast, unschedule_broadcast) are conditional on the status they read: if the dashboard or the sender changed the broadcast a moment earlier, the call is refused with a message to read it again rather than racing a send in flight.

Audiences: everyone, segment or webinar

create_broadcast requires audience, one of: The ids must belong to your organization; an unknown one is refused. Mixing fields is refused too: segment_id only goes with segment, webinar_id and the webinar_* fields only go with webinar.
The audience is a definition, not a frozen list. plan_broadcast counts it now; it is resolved again when the broadcast sends, so people who join the segment or register for the session before then get it. People who opted out, bounced or complained are not subtracted from the plan’s count: they are skipped one by one at send time and show up as skipped in get_broadcast_stats.

Webinar audiences and timing from the session

A broadcast with audience: "webinar" goes to one session’s registrants. Three optional fields shape it: webinar_anchor is only accepted together with webinar_offset_minutes. A broadcast timed from its session moves with the session: its send time is computed from the session’s start (or end), and if the session is rescheduled the time follows it. Plan it without send_at. A reminder that goes out an hour before the start, to everyone registered:
A replay email to no-shows two hours after the session ends:
A message anchored to the start with a positive offset (before the start) is a pre-webinar message: once the session has started, the sender stops it, and plan_broadcast raises webinar_started. Messages at offset 0, after the start, or anchored to the end are not stopped. A canceled session’s broadcasts are never sent (webinar_canceled). To see every message a registrant of a session receives, broadcasts and sequences together, use get_webinar_plan (see /mcp/webinars). list_broadcasts with webinar_id lists just one session’s broadcasts.

Email and text broadcasts

channel is set once, on create_broadcast: email (the default) or sms. It cannot be changed afterwards with update_broadcast; create a new broadcast instead. Text broadcasts go to a webinar session’s registrants only. Creating one with audience: "everyone" or "segment" is refused. Email-only fields (subject, preview_text, html, sender_id) are refused on a text broadcast, and message_body is refused on an email. To schedule, an email needs a subject, a non-empty html and a sender_id; a text needs a message_body and an SMS number connected to the organization. iMessage numbers cannot send broadcasts. message_body is plain text of up to 1600 characters; over 480 characters plan_broadcast warns (long_text) that it goes out as several messages. subject and preview_text are sent exactly as written. Variables do not work in them: any {{ in either field is refused.

Senders: list_senders

list_senders takes no arguments and returns who a broadcast can be sent from:
  • Pass an email sender’s id as sender_id on create_broadcast or update_broadcast. A sender_id that is not one of your organization’s senders is refused.
  • ready is true only when the sender’s sending domain is verified. You can save a draft with a sender that is not ready, but plan_broadcast blocks it (sender_domain_missing or sender_domain_unverified) until the domain is verified.
  • sms.connected says whether text broadcasts can be sent at all. Text broadcasts have no sender_id; they always go from the organization’s SMS number.

Variables: personalizing a broadcast

Broadcasts use Flywheel’s one variable system, the same one the dashboard editor saves and workflow messages use. Two pieces work together:
  1. The body (html or message_body) holds placeholders: {{var_<id>}} where a value goes in text, or {{link_var_<id>}} as a link’s whole href.
  2. The variables map says what fills each placeholder, keyed by its id.
For a person named Ada on the Pro plan this renders “Hi Ada, You are on the Pro plan.” For a person with neither value it renders “Hi there, You are on your current plan.” The fallback replaces the whole value, template included, so write it to fit the sentence on its own. Ids are var_ or link_var_ followed by letters, digits, _ or -. Every placeholder in the body must be defined in variables; definitions the body does not use are dropped when the broadcast is saved.

Discovering what variables can read: list_broadcast_variables

list_broadcast_variables takes no arguments and returns every path a variable can read, in three groups:
(person is abbreviated here; it lists every field of the recipient, such as last_name, primary_email, mrr and subscription_status.) Each entry’s variable string goes straight into a variable definition’s variable field. Custom properties are addressed by their key (custom_properties.<key>), not their display name. The webinar paths only work on a broadcast whose audience is a webinar session; on any other broadcast they are refused.

Variable definition fields

Each entry in variables is an object with these fields: Values are inserted as-is, and newlines in a value become <br> in an email. A date example, rendering a renewal date as “Oct 14”:
When a link’s URL comes from a variable, the placeholder is a link_var_ id and it must be the link’s whole href:
Rules, all enforced by create_broadcast and update_broadcast:
  • A link_var_ placeholder goes in an <a href> (as the entire value) or inside a button (see the HTML rules below). It is refused in text.
  • A var_ placeholder is refused in an href or a button: the dashboard editor drops var_ ids there on the first edit, after which the link would render empty.
  • A link variable’s variable must be exactly one {{context.root_context.…}} path, with nothing around it.
  • To link an image, wrap it: <a href="{{link_var_join}}"><img src="https://…" alt="Join"></a>. Variables in src or any other attribute are refused.
On a broadcast with audience: "webinar", variables can read the session as each recipient sees it: webinar_title, webinar_start_time (already formatted in the session’s timezone, so it needs no date_format), join_url (the recipient’s personal join link, falling back to the session’s link) and three add-to-calendar links that carry the recipient’s join link. A complete reminder email body:
Moving a broadcast off its webinar (changing audience with update_broadcast) is refused while its variables still read root_context.webinar; send replacement variables in the same call.

Variables in text broadcasts

A text’s message_body uses {{var_<id>}} placeholders the same way. Texts are plain, so there is no link markup and no link_var_: put a URL variable straight into the text.
A text body may also contain a raw {{context.root_context.…}} path, which the dashboard’s text editor writes for a link’s URL; it must resolve. Older merge tags such as {{first_name|there}} are refused: use a variable.

What the variable checks refuse

Silent blanks are refused up front rather than sent. create_broadcast and update_broadcast reject:
  • A placeholder with no definition in variables.
  • A path that does not exist in a broadcast’s context (a typo, or root_context.webinar on a broadcast that is not tied to a session). list_broadcast_variables is the list of valid paths.
  • A path outside root_context (other context exists only inside workflows), and a raw {{context.…}} path written directly in an email body: put it in a variable and write {{var_…}}.
  • Any other template syntax in the body ({{first_name}}, {{#if}}), an unclosed {{, and variables inside HTML comments.
  • A template that uses anything other than {{var}} (and {{context.root_context.…}} paths).
  • Variables in subject or preview_text.
plan_broadcast re-checks what is stored and raises variable_missing (error) or variable_unresolvable (warning) if something slipped through, for example a body edited in the dashboard.

Email HTML body rules

html is the email’s body only. The organization’s email layout (logo, footer, unsubscribe link) wraps it at send, so don’t write your own footer or unsubscribe link. If you pass a full document (<!doctype>, <html>, <head>), only the contents of <body> are kept. A broadcast written through the MCP also opens in the dashboard editor, and the body must survive being edited there. Keep to what the editor understands: p, h1–h3, strong, em, a, ul / ol / li, img, hr, br, and buttons.

Images and paragraphs

The editor’s image is a top-level block, and its parser drops an image it finds inside a paragraph. When the dashboard reopens an API-written body, it lifts each image out of its paragraph (splitting the paragraph around it) so it survives, but the cleanest body puts images at the top level from the start:
rather than <p>Here is what shipped <img src="…"> this month.</p>. A linked image is an <a href> wrapping only the <img>.

Buttons

A button is an <a data-button>. Inside a button, link_var_ placeholders are allowed in its href, its data-button-text and its text:
var_ placeholders are refused inside a button.

Markup that is refused

create_broadcast and update_broadcast refuse:
  • <script>, <iframe>, <frame>, <frameset>, <object>, <embed>, <applet>, <form>, <input>, <textarea>, <select> and <base>.
  • Inline event handlers (onclick and any other on… attribute).
  • javascript: URLs.
  • Variables anywhere other than text, a link’s href, or a button.
plan_broadcast also warns (gmail_clipping) when the body is over 102 KB, because Gmail clips messages that size behind “View entire message”.

Creating a draft: create_broadcast

create_broadcast creates a draft and sends nothing. Required: audience (and its id). Everything else can be filled in now or later with update_broadcast.
The result is { "broadcast": { … }, "created": true }, where broadcast has the same shape get_broadcast returns. The draft appears in the dashboard’s Broadcasts list and opens in its editor like any other.

Reading broadcasts: list_broadcasts and get_broadcast

list_broadcasts returns summaries, newest first, with total for paging:
Filters: status (draft, scheduled, sending, sent, stopped, error), channel (email, sms), webinar_id (one session’s broadcasts). limit defaults to 50, max 200; offset defaults to 0. Each summary has id, channel, status, subject, audience, timing, sender_id, counts (sent, opened, clicked) and created_at, but no body. get_broadcast takes { "broadcast_id": "…" } and returns the full broadcast:
The body and variables are in the form update_broadcast takes, so you can edit them and send them back. timing.send_at is the scheduled time, or a webinar broadcast’s time computed from its session. edited_in_dashboard is true when the body was last shaped in the dashboard editor rather than written through the API.

Editing a draft: update_broadcast

update_broadcast changes a draft. Pass broadcast_id plus only the fields you are changing.
  • Omitted fields stay as they are. null clears preview_text, sender_id or webinar_offset_minutes.
  • html or message_body replaces the whole body. Read it with get_broadcast first and send back the full edited body.
  • variables replaces the whole variable map. Sending html without variables re-checks the new body against the existing definitions; sending variables without a body re-checks the stored body against the new map. Either way, any placeholder left undefined is refused.
  • Audience: any audience field re-validates the whole audience. Within the same kind, the stored ids and timing are kept unless you send new ones. Switching kind (for example segment to webinar) needs the new kind’s id.
  • channel cannot be changed.
  • A scheduled broadcast is refused: call unschedule_broadcast first. Editing a stopped or error broadcast returns it to draft.
  • Any successful change invalidates an earlier plan: call plan_broadcast again.
Change only the subject:
Switch a webinar broadcast from timed-from-session to a fixed time you will pick at plan time:
The result is { "broadcast": { … } } in the get_broadcast shape.

Planning a broadcast: plan_broadcast

plan_broadcast shows exactly what scheduling would do, without scheduling anything or writing anything:
  • send_at is ISO-8601 with an offset, at least an hour from now and at most a year ahead.
  • A webinar broadcast timed from its session (webinar_offset_minutes set) takes no send_at: it plans for its time from the session. Passing one is an error (timed_from_webinar).
The response:

Plan checks: errors and warnings

Each check is { "level": "error" | "warning", "code": "…", "message": "…" }. Any error blocks scheduling: ready is false and no plan_token is issued. Warnings do not block, but read them and pass them on to the person you work for. Errors: Warnings:

Scheduling a broadcast: schedule_broadcast

schedule_broadcast commits a plan. It is the only broadcast tool that reaches real people, and needs the broadcasts.send permission.
It re-evaluates the broadcast exactly as plan_broadcast did, then schedules it for the planned time:
(broadcast is abbreviated; it is the list_broadcasts summary shape.)
Only call schedule_broadcast after the person you work for has seen the plan (recipients, send time, sender, preview and every check) and agreed to it.

What invalidates a plan_token

schedule_broadcast refuses the token, and you must call plan_broadcast again (and show the new plan), when:
  • The plan expired. A token is good for one hour from when it was issued (expires_at).
  • The broadcast changed since it was planned. The token covers the channel, subject, preview text, HTML body, text body, variables, email layout, sender, segment, the segment’s filters (editing the segment’s definition counts, even though the broadcast itself was not touched), webinar session, webinar_audience, webinar_offset_minutes, webinar_anchor and the send time. For a broadcast timed from its session, rescheduling the session changes the send time and invalidates the plan.
  • A check now blocks it. The checks run again at schedule time. If the planned send_at is now less than an hour away, the sender’s domain lost verification, the session was canceled or started, or the allowance ran out, the call is refused with the blocking messages.
  • The broadcast is no longer a draft (or stopped / errored). A scheduled one has to be unscheduled first; a sent one cannot be scheduled again.
  • The token is not from plan_broadcast. Anything that does not look like a plan token is refused.
What does not invalidate a token: the audience growing or shrinking (people joining a segment or registering), because the audience is resolved again at send time anyway.

The one-hour minimum

Every broadcast scheduled through the MCP goes out at least an hour after it is scheduled. This is a safety policy: a committed broadcast sits, visible in the dashboard, for at least an hour, where a person (or you, with unschedule_broadcast) can pull it back. There is no “send now” tool. The minimum applies twice: plan_broadcast refuses a send_at (or a time from the session) less than an hour away, and schedule_broadcast checks again at commit time. Because a plan is valid for up to an hour, a send_at planned at exactly one hour out falls under the minimum within a minute. Leave a margin: plan at least 90 minutes out, or more if the person you work for may take a while to review.

Unscheduling: unschedule_broadcast

unschedule_broadcast takes a scheduled broadcast back to draft so nothing goes out:
It returns { "broadcast": { … } } with status: "draft". It works up to the moment sending begins and is refused once the broadcast is being sent, or if it is not scheduled. A broadcast timed from its session keeps its offset and its time from the session; a fixed-time broadcast loses its send_at and needs a new one at the next plan_broadcast. People can also unschedule from the dashboard (Unschedule on the broadcast).

Deleting: delete_broadcast

delete_broadcast deletes a broadcast nobody has received any of: a draft, or a stopped / error broadcast that never went out.
It returns { "deleted": true }. It is refused for a scheduled broadcast (call unschedule_broadcast first) and for any broadcast that went out, even partly, which stays for its history and stats. Deletion cannot be undone; confirm with the person you work for first.

Stats: get_broadcast_stats

get_broadcast_stats reports how a broadcast did:
  • totals and daily: sends, opens and clicks for email. For a text broadcast, totals.sent is the sent count and opens and clicks are 0.
  • recipients: where every queued recipient ended up: scheduled (queued, not picked up yet), sending, sent, failed, skipped.
  • skipped_reasons: why skipped recipients were skipped: opted out of marketing email, bounced, complained, already received this broadcast (duplicate), the email allowance ran out mid-send, or a pre-webinar message whose moment passed once the session started.

Worked example: from draft to scheduled

A product-update email to a segment, scheduled for next Tuesday at 3pm New York time.
1

Find the audience and a ready sender

Call list_segments and pick the segment’s id. Call list_senders and pick an email sender with "ready": true.
2

Look up the variable paths

Call list_broadcast_variables. The first name is {{context.root_context.org_user.first_name}}; the plan name is a custom property, {{context.root_context.org_user.custom_properties.plan_name}}.
3

Create the draft

Call create_broadcast:
The result has "created": true and "status": "draft". Nothing has been sent.
4

Plan it

Call plan_broadcast with a send_at comfortably more than an hour out:
If ready is false, fix what each error check says with update_broadcast and plan again.
5

Show the plan to the person you work for

Summarize it in plain terms: “This goes to 1,284 people in Active customers on Tuesday Oct 13 at 3pm ET, spread over three hours, from Jaen <jaen@mail.acme.com>. Here is how it reads for one recipient: … No warnings.” Include every warning, and the rendered preview. Wait for an explicit yes.
6

Schedule it

Within the hour the plan is valid, and only after the go-ahead, call schedule_broadcast:
The result says when it goes out and how to pull it back. If it is refused because the plan expired or something changed, plan again and show the new plan.
7

Check how it did

After the send time, call get_broadcast_stats for opens, clicks and skipped recipients.

Common tasks

Send a reminder to a webinar session’s registrants

Create a broadcast timed from the session, then plan without send_at:
Then plan_broadcast with { "broadcast_id": "…" } only, review, and schedule_broadcast. list_senders must show "sms": { "connected": true }. Then:

Change the send time of a scheduled broadcast

unschedule_broadcast, then plan_broadcast with the new send_at, show the plan, then schedule_broadcast with the new token. A scheduled broadcast cannot be edited or re-planned in place.

Fix a typo in a scheduled broadcast

unschedule_broadcast, get_broadcast to read the body, update_broadcast with the corrected full html, then plan, review and schedule again.

Resend to people who did not get a stopped broadcast

A stopped or error broadcast can be planned again (sent_before warns); anyone who already received it is skipped as a duplicate. Update it if needed, plan, review, schedule.

See what is going out soon

list_broadcasts with { "status": "scheduled" }; each summary’s timing.send_at says when.

Copy a broadcast

get_broadcast on the original, then create_broadcast with its subject, preview_text, html, variables (as returned), sender_id and the audience you want. A text broadcast copies message_body and variables instead.

Mistakes to avoid

  • Scheduling without showing the plan. schedule_broadcast reaches real people. Show the recipients, send time, sender, preview and every warning first, and wait for a yes.
  • Planning exactly one hour out. The minimum is checked again at schedule time; a send_at at 60 minutes is too soon a minute later. Plan 90 minutes or more ahead.
  • Reusing an old plan_token after an edit. Any update_broadcast (or an edit to the target segment, or a session reschedule) invalidates it. Plan again and show the new plan.
  • Passing send_at to a broadcast timed from its session. It is an error (timed_from_webinar). Omit it, or set webinar_offset_minutes to null first.
  • Getting the offset sign backwards. Positive webinar_offset_minutes is before the anchor; negative is after.
  • Variables in the subject. subject and preview_text are sent as written and {{ is refused there. Personalize the body only.
  • Inventing merge tags. {{first_name}}, {{first_name|there}} and {{context.…}} written directly in an email body are refused. Use a {{var_<id>}} placeholder and a definition in variables.
  • Guessing variable paths. Read them from list_broadcast_variables. Custom properties are addressed by key, not display name.
  • Using var_ in an href or link_var_ in text. A link’s URL is <a href="{{link_var_<id>}}"> as the whole href; text uses var_.
  • Sending only the changed part of the body. html, message_body and variables each replace the whole value. Read with get_broadcast, edit, send the whole thing back.
  • Putting images inside paragraphs. Put <img> at the top level of the body (or inside a link that wraps only the image) so it survives the dashboard editor.
  • Writing a footer or unsubscribe link. The organization’s email layout adds them at send.
  • A text broadcast to a segment. Texts go to webinar registrants only, and need an SMS number.
  • Treating recipients as final. It is a count now; the audience is resolved again at send, and opted-out, bounced and complained people are skipped then.
  • Not retrying safely. Pass your own v4 broadcast_id to create_broadcast so a retried call returns the same draft instead of creating a second one.
  • Ignoring the everyone warning. audience: "everyone" is the organization’s whole list. Make sure the person you work for means it.