Broadcasts
WhatsApp campaigns over the flow API — a campaign is a flow, the four statuses, the audience as a segment, the schedule in the bot's zone, and the template catalog you cannot write to.
What it is
There is no campaign entity on the Chatfuel API. A campaign is a flow holding two blocks: an
entry point that carries the audience and the schedule — a WhatsAppOneTimeNotificationBlock
for "send now", a WhatsAppScheduledMessageBlock for "later" and "on a repeat" — connected to a
WhatsAppTemplateBlock that carries the message. One pair mutation creates both blocks and the
connection; every setter afterwards is keyed by one of the three ids on that pair. The module
reads every flow on the bot, keeps the ones shaped like that, and derives a campaign record from
each. The dashboard's flow builder shows the same flows.
Nothing in the API writes a WhatsApp template. Templates are written and submitted in WhatsApp Manager and read here; the Templates tab is that catalog with each template's Meta status, a refresh that asks Meta for the latest, and a door to WhatsApp Manager.
Installed as
| Wizard id | broadcasts |
| Install it | npx @chatfuel/wizard --modules broadcasts |
| Selected by | Default — --yes installs it |
| Requires | Nothing, beyond the implicit core |
| Recommends | contacts, channels |
| Skill | chatfuel-broadcasts |
Neither hidden nor railHidden; it sits in the rail's growth group beside Publishing. The two
recommendations are the audience's two ends: contacts is where the attributes an audience is
built from are edited, and channels is where the WhatsApp number this module sends from gets
connected — without a number the module shows the connect state on every screen.
Routes and views
The view is a path segment; everything else is a query parameter. The campaign list is the default, so it has no segment of its own.
| Path | What it shows |
|---|---|
/broadcasts | The campaign list, with a panel beside it for the campaign ?c= names. |
/broadcasts/compose?c=<flow> | The composer over one draft: name, message, audience, schedule, review. |
/broadcasts/templates | The catalog on the bot's number, with Meta status. |
| Parameter | What it does |
|---|---|
c | The campaign — a flow id — whose panel or composer is open. |
step | Composer only: name, message, audience, schedule or review. Absent means the first step not yet done. |
status | List only: draft, scheduled, sending or sent. Absent means every status. |
q | The search box, on the list and on the catalog. |
t | Catalog only: the template whose preview is open. |
An unknown value falls back in silence, and defaults are left out of what is written. ⌘K
opens the command palette anywhere in the module and ? the keyboard sheet.
The model
Four statuses, derived on every read. The entry point's element carries a status
(Draft, Live, Paused, Finished) and the block an isEntryPointEnabled bit, and the
campaign's status is read off both against the clock:
| Kind | Element | Bit | Campaign |
|---|---|---|---|
| One-time | Draft or Paused | — | draft |
| One-time | Live | — | sending |
| One-time | Finished | — | sent |
| Scheduled | Finished | — | sent |
| Scheduled | any | off | draft |
| Scheduled | any | on, first send ahead or repeating | scheduled |
| Scheduled | any | on, once and the time has passed | sending, until the next read says Finished |
There is no paused state: blockDisableEntryPoint puts a scheduled element back to Draft, so
"take off the schedule" makes a draft with everything kept. A one-time pair is born with its bit
on and the bit cannot be turned off — the element's status is what counts. A row that is
sending is re-read every ten seconds until it is not; a one-shot that is sending by the clock
alone is re-read for an hour past its time, and Refresh after that.
Arming is silent when it fails. blockEnableEntryPoint re-validates the campaign and, when
any verdict stands, answers the flow unchanged with the bit still off and no error. The module
reads the bit back and shows the verdicts rather than assuming. Verdicts are data —
blockElements[].errors[].code, snake_case, recomputed on every write — and each is printed on
the review step under the step that fixes it. A thrown refusal sits at
extensions.errors[0].extensions.code, one level down through the router; an unregistered one
arrives as a bare InternalServerError, which is what every write to a live schedule answers.
So the module disarms before it writes a time, and arms again on confirm.
Times are instants; weekdays are UTC. The first send goes up as an ISO instant and comes
back as a Z string. A repeat's weekdays are stored normalised to UTC, so a Monday 01:00
campaign on a bot three hours east of Greenwich is stored as Sunday. The module shifts the list
by the bot zone's day offset at the first send on the way out and on the way in, and sends the
corrected list with every first-send-time write while the stored list is non-empty. The order of
writes is the server's: repeat type, then its list, then the first send time last. The zone is
the bot's; an operator's own zone is the fallback when the bot has none.
The audience is a segment. The entry point holds a SegmentInput with client-minted UUIDs;
"everyone" is a segment with no filters and is valid as it stands. The recipient count is
contactsTotalCount(platforms: [whatsapp], segment) with the very same segment. The builder
writes two levels — the segment's own filters and one nested segment per filter slot — and reads
three, keeping whatever it cannot show (a tag, a stored segment, a date strategy, a third level)
as passthrough that is re-sent as read. Writes and counts are debounced together and the count
is epoch-guarded, so a figure never describes a filter that has since changed.
An unknown attribute name in a template parameter creates the attribute. {{Attribute name}} in a blank becomes a reference the server parses; a name the bot does not have is
accepted and a custom attribute is created for it, with no API to delete one. The composer's
Insert field offers only botAttributes(platforms: [whatsapp]), and text typed by hand is sent
as text. In an audience filter an unknown name is harmless — it selects nobody.
A block never changes type, and there is no clone. "Send now" on a scheduled draft creates the other pair on the same flow, replays the template and the audience onto it, and deletes the old entry point; the old template block cannot be deleted and stays in the flow, unconnected. Duplicate is a new flow with the source replayed onto it — template, every filled blank, media by file id, the segment with fresh ids, the repeat type and its list, never the first send time. Neither is one operation, and a partial failure keeps what was made and lists what is missing.
There are no delivery figures. WhatsAppTemplateBlock.stats answers a server error on
every read, so the module never selects it. The only count the API has is
sentToContactsCount on a finished one-time element, and it is sometimes null.
Configuration
| Variable | Notes |
|---|---|
CHATFUEL_TOKEN | Secret. Server-side only. |
VITE_CHATFUEL_WORKSPACE_ID | The workspace you picked during the wizard run. |
CHATFUEL_API_BASE | Defaults to https://panel.chatfuel.com. |
Nothing of its own. The number it sends from is connected in the Channels module, and the module asks for the template catalog only once the bot has one.
Permissions
| Object | Action | Required for |
|---|---|---|
Flows | View | Listing campaigns — every campaign is a flow, read through the bot's flow groups. |
Flows | Edit | Creating, filling, scheduling, sending, taking off the schedule and deleting. Every write is a flow-builder block mutation, and the server checks this, not Broadcasting. |
Bot | View | The template catalog, the bot's time zone and the connected number. |
People | View | The attribute catalog behind the audience builder and Insert field, and the recipient count. |
Limits
The five time setters refuse while the campaign is armed, with a bare server error that
names nothing; the module disarms first. A one-time campaign that has started is immutable:
send and segment both answer WhatsAppOneTimeBroadcastAlreadyStarted, and the module treats
that as "already sent" and re-reads. The catalog ignores paging and answers the whole list.
Never call deleteFlowGroup — it deletes every flow in the group, campaigns included.
The module's own ceilings:
| Days between repeats | 1 to 365 — the server refuses above 1000 with a bare error |
| Dates in a repeat | 500 |
| Copy code | 15 characters |
| Text blank | 1,024 characters |
| Link parameter | 2,083 characters |
| Conditions in an audience | 20 |
| Groups in an audience | 10 |
| Campaign name | 120 characters |
| Wait before a filter change is written and counted | 400 ms |
| A sending row re-read | every 10 seconds, for an hour past a one-shot's time |
| Catalog read after a Meta refresh | at 3 s and again at 10 s |
Broadcasts, as a product
What your clients send with it, the three decisions, and where a campaign is — from the person sending it.
Contacts
Recommended alongside this one: the attributes an audience is built from, and where they are edited.
Channels
Recommended alongside this one: where the WhatsApp number this module sends from gets connected.
Your agent's notes
The wizard installs .claude/skills/chatfuel-broadcasts/ (Codex: .agents/skills/chatfuel-broadcasts/) — references/campaign-model.md walks a campaign from nothing to sent, schedule.md has the weekday arithmetic, audience.md the segment, templates.md the blanks and the WhatsApp Manager hand-off.
Publishing
Instagram publishing over four one-way mutations — the three views, the queue the API does not have, and the failure that has already succeeded.
Ads Optimization
Conversion reporting for click-to-WhatsApp ads — event sets over one automation scope, the seven triggers, and the permission that decides whether any of it is delivered.