Recurring broadcasts
A recurring definition is a template + list + bot + cron cadence
that creates a fresh broadcast (a child) every time the schedule
comes round. Every child broadcast enters the normal
preparing → ready → running → completed pipeline, so recurring runs
show up in /broadcasts alongside your one-offs.
Access
Bot-scope from the caller's JWT bots claim applies, a definition on
a bot the caller can't act on returns 403 bot_not_assigned on writes
and is filtered out on reads.
The bot must be able to broadcast
A recurring definition is a broadcast generator, so create, update and
resume take the same registration check as a one-off broadcast: a bot
registered transactional or otp is refused with 403
bot_not_promotional. See
Broadcasts
for the response shape, and filter on can_broadcast from
GET /rcs/bots to avoid it.
Pause and delete are never refused. A schedule you can create but cannot turn off would be worse than one you cannot create, so only the write paths that put messages on the carrier are gated.
The registration is checked again on every run, not just at save time. An administrator can correct a bot's traffic type long after a cron was set up; when that happens the schedule stops producing children and stays put for you to repoint or delete, rather than being silently deactivated.
Endpoints
GET /api/v1/rcs/recurring-broadcasts
POST /api/v1/rcs/recurring-broadcasts
PATCH /api/v1/rcs/recurring-broadcasts/{id}
DELETE /api/v1/rcs/recurring-broadcasts/{id}
POST /api/v1/rcs/recurring-broadcasts/{id}/pause
POST /api/v1/rcs/recurring-broadcasts/{id}/resume
GET /api/v1/rcs/recurring-broadcasts/{id}/children?limit=50Create body
{
"name": "Weekly newsletter",
"bot_id": "3c9…",
"template_id": "48b…",
"list_id": "9ff…",
"audience": "rcs",
"cron_expression": "0 9 * * 1",
"tz": "Asia/Kolkata",
"ab_test": false,
"ab_template_a": "",
"ab_template_b": "",
"ab_split_ratio": 0,
"active": true
}Set ab_test to true and both ab_template_a + ab_template_b (each
must be an approved template) to run every child as an A/B test.
Split defaults to 50/50 when ab_split_ratio is out of range.
audience is optional and says who each run sends to, with the same
values as a one-off broadcast: rcs
(the default, also when missing or ""), not_rcs or all. Any other
value is refused with 400 invalid_audience. A PATCH without
audience keeps the stored value. The list rows from GET include
audience too (definitions saved before the field read rcs).
Each run copies audience to the child broadcast it makes, counts the
list again for it when it runs, and holds the wallet for that count (all
numbers that have not opted out while a check on the list is still
running). Create and update are not refused when the list reaches nobody
today: each run counts again. Opted-out numbers are never sent.
Cron syntax
Standard 5-field POSIX cron:
minute hour day-of-month month day-of-week
0-59 0-23 1-31 1-12 0-6 (0=Sunday)Supported per field:
- Literal integers (e.g.
9). - Comma-separated lists (e.g.
1,3,5). *for "every value".
Ranges (1-5), steps (*/15), and named tokens (SUN) are not
supported in v1, the panel's cadence picker emits only the subset the
parser accepts. If you need richer syntax, use the "Advanced" tab in
the panel modal and type the raw expression; the parser will reject
anything it can't handle at create-time with a 400.

Examples
Timezone semantics
tz is any valid IANA name (Asia/Kolkata, America/New_York, …).
The cron ticks in the given zone; next_run_at is always stored as
UTC. next_run_at moves on after each child is created.
Pause / resume
Paused definitions stop ticking without losing history.
POST /api/v1/rcs/recurring-broadcasts/{id}/pause
POST /api/v1/rcs/recurring-broadcasts/{id}/resumeResume recomputes next_run_at from the current cron so a definition
paused during a peak doesn't fire a burst of catch-up broadcasts.
Children
Every child created is recorded against its definition, so the
parent → child relationship can be read on its own. Each child
broadcast also carries parent_recurring_id, which points back to the
definition.
GET /api/v1/rcs/recurring-broadcasts/{id}/children?limit=50Response carries each child's status + counters so the panel list
can render terminal state without a second call.
Failure modes
- Deleted list / empty list, the run is skipped and
next_run_atadvances. The definition stays active. - Nobody to send to for its
audience, every number opted out, the list's check, made with any of your bots, found every number unreachable (rcs), or no number is marked unreachable (not_rcs, reasonnone_cannot_get). The same rule asno_reachable_recipients. The tick is skipped andnext_run_atadvances; no child broadcast is created. The definition stays active. Withrcs, numbers the bot found not on RCS in the last 30 days count as nobody here too. - Daily spend limit reached, a run that falls due after today's
spend limit was reached is skipped the same
way: no child broadcast,
next_run_atadvances, the definition stays active. A child that was already created and falls due after the limit was reached does not start: it endsfailedwithstatus_reason: "spend_limit_reached"and nothing is sent. Creating, updating and resuming a recurring broadcast are not refused by the limit. - Cron parse failure, an update that lands an invalid expression
pauses the definition (
active = false). The panel shows the parse error inline; fix the expression + resume. - Wallet insufficient, the run's hold is taken when its child is
created. A child that cannot be held ends
failedand is never prepared or sent. The schedule carries on:next_run_atadvances and the next run tries again. - Bot no longer registered for broadcasts, the run is skipped
and
next_run_atadvances. The definition stays active and untouched; repoint it at a bot wherecan_broadcastistrue, or delete it.
SSE
Each child created is announced as recurring.materialized on the
account's SSE channel:
{
"type": "recurring.materialized",
"data": {
"recurring_id": "de…",
"child_broadcast_id": "9c…",
"name": "Weekly newsletter",
"materialized_at": "2026-07-16T03:30:00Z"
}
}The panel's /broadcasts/recurring list keys off it to bump
child_count without a re-fetch.