Migrate from Broadcasts to Campaigns
Campaigns replace Broadcasts. A campaign separates who you send to from what you send and where, so one campaign can reach a subscriber on more than one channel in sequence. A broadcast is locked to a single integration for its whole life.
The Broadcasts API keeps working, and every Broadcasts endpoint in this reference is marked deprecated. New delivery capability lands in Campaigns and is not backported.
Endpoint mapping
| Broadcasts (deprecated) | Campaigns |
|---|---|
POST /v1/broadcasts | POST /v1/campaigns then POST /v1/campaigns/{campaign_id}/senders |
GET /v1/broadcasts | GET /v1/campaigns |
GET /v1/broadcasts/{broadcast_id} | GET /v1/campaigns/{campaign_id} |
PUT /v1/broadcasts/{broadcast_id} | PUT /v1/campaigns/{campaign_id} |
DELETE /v1/broadcasts/{broadcast_id} | DELETE /v1/campaigns/{campaign_id} |
GET /v1/broadcasts/{broadcast_id}/subscribers | GET /v1/campaigns/{campaign_id}/subscribers |
PUT /v1/broadcasts/{broadcast_id}/subscribers/bulk-upsert | PUT /v1/campaigns/{campaign_id}/subscribers/bulk-upsert |
PUT /v1/broadcasts/{broadcast_id}/subscribers/bulk-remove | PUT /v1/campaigns/{campaign_id}/subscribers/bulk-remove |
PUT /v1/broadcasts/verify-subscribers | PUT /v1/campaigns/verify-subscribers |
POST /v1/broadcasts/send-now | POST /v1/campaigns/send-now |
GET /v1/broadcasts/check_name | GET /v1/campaigns/check_name |
The paths match, but two rows are not drop-in URL swaps. See Recipients per send-now call and Removing subscribers.
Campaigns add endpoints with no Broadcasts equivalent:
| Endpoint | Purpose |
|---|---|
GET, POST on /v1/campaigns/{campaign_id}/senders | List and create senders |
GET, PUT, DELETE on /v1/campaigns/{campaign_id}/senders/{sender_id} | Read, update, delete one sender |
GET /v1/campaigns/{campaign_id}/subscribers/{subscriber_id} | Read one subscriber |
POST /v1/campaigns/{campaign_id}/subscribers/{subscriber_id}/stop | Stop sending to one subscriber |
POST /v1/campaigns/{campaign_id}/subscribers/{subscriber_id}/resume | Resume one subscriber |
PUT /v1/campaigns/{campaign_id}/subscribers/verify | Validate a subscriber payload before upserting it |
GET /v1/campaigns/{campaign_id}/analytics | Campaign analytics |
PUT and DELETE live on /v1/campaigns/{campaign_id}/senders/{sender_id}, not on the collection. Issuing them against /senders returns 404.
Payload changes
| Broadcast field | Campaign equivalent | What to do |
|---|---|---|
channel | none | Remove it. The channel is resolved from the sender's integration_id. |
integration_id | integration_id on the sender | Move it out of the campaign body and into the sender body. Required there. |
actions | template on the sender | actions was an array of up to 5 items; template is a single object. For a WhatsApp sender the object is validated against the same schema a broadcast actions[] element used, so you can copy actions[0] across verbatim. |
external_id on each subscriber | phone or email | Split the identifier by kind. At least one of the two is required. phone is normalized to E.164, so send it in E.164. |
context | subscribers_context | Rename. Default tagging is unchanged: send subscribers_context without tags and the campaign name is slugified into one, exactly as the broadcast name was. Omit subscribers_context entirely and the campaign is left untagged, as it was with broadcasts. |
scheduled_at | schedule.start_at | Move one level down. Both take a future epoch timestamp in milliseconds (number or numeric string) or the literal "now". An ISO 8601 datetime is rejected, and so is a seconds epoch. |
| none | type | Required. Use marketing, sales or utility. |
| none | is_agentic, nba_config | Optional. See What Campaigns add. |
force_new_sessions | none | See Session behavior. |
session_timeout | none | See Session behavior. |
schedule also accepts end_at (epoch milliseconds, must be after start_at) and a send_window.
type also accepts payment_recovery, but it is not a broadcast migration target. Choosing it turns on extra per-subscriber requirements: every subscriber must carry debt context. A plain broadcast-style subscriber list is rejected with 400.
Per-subscriber context and metadata
Both fields keep their names, but campaigns validate them more strictly than broadcasts did:
context.debtis no longer accepted. Usecontext.debts[].context.contact_id,alternate_phonesandalternate_emailsare type-checked, so values a broadcast accepted as free-form can now return 400.- Broadcast
metadataaccepted onlysourceand silently discarded everything else. Campaignmetadataalso acceptsskip_fallback.
Custom keys inside context still flow through untouched.
Migration steps
A broadcast was three calls. A campaign is four, because the channel now lives on its own object.
Before
POST /v1/broadcasts
{
"name": "Summer Sale 2026",
"channel": "whatsapp",
"integration_id": "6c5fa724-c208-4ac6-afd7-798f4738c8e0",
"scheduled_at": "now",
"context": { "tags": ["summer_sale_2026"] },
"actions": [
{ "id": "1234567890", "name": "summer_sale_2026", "language": "pt_BR" }
],
"brain_parent_id": "123fc8f5-fd90-41df-9c42-87457a3acf15",
"brain_version": 0
}
PUT /v1/broadcasts/{broadcast_id}/subscribers/bulk-upsert
{ "channel": "whatsapp",
"subscribers": [ { "external_id": "+5511999999999", "context": { "user": { "display_name": "Ana" } } } ] }
PUT /v1/broadcasts/{broadcast_id}
{ "status": "ready" }
After
POST /v1/campaigns
{
"name": "Summer Sale 2026",
"type": "marketing",
"subscribers_context": { "tags": ["summer_sale_2026"] },
"schedule": { "start_at": "now" },
"brain_parent_id": "123fc8f5-fd90-41df-9c42-87457a3acf15",
"brain_version": 0
}
POST /v1/campaigns/{campaign_id}/senders
{
"integration_id": "6c5fa724-c208-4ac6-afd7-798f4738c8e0",
"template": { "id": "1234567890", "name": "summer_sale_2026", "language": "pt_BR" }
}
PUT /v1/campaigns/{campaign_id}/subscribers/bulk-upsert
{ "subscribers": [ { "phone": "+5511999999999", "context": { "user": { "display_name": "Ana" } } } ] }
PUT /v1/campaigns/{campaign_id}
{ "status": "ready" }
Create the campaign as draft and flip it to ready with the PUT, after the subscribers are uploaded. Only the draft to ready transition schedules the start.
POST /v1/broadcasts accepted status: "ready" directly, and a ready broadcast whose scheduled_at has passed is picked up and sent without a further call. If your broadcast flow relied on that, keep the campaign in draft until the subscriber upload has finished.
Template shape depends on the integration
template is validated against the sender's integration type, resolved from integration_id:
| Integration type | Template shape |
|---|---|
id, name and language required, plus the optional components array. Identical to a broadcast actions[] element. | |
| SMS, RCS, voice | text |
subject and body | |
Viber (mstat-viber) | text |
A { "text": ... } template on a WhatsApp sender is rejected with 400 invalid-template.
Variables in templates
The placeholder is {{$path}}, resolved against the subscriber's context object. The context. prefix is not part of the path:
{{$user.display_name}}
Array elements accept both {{$debts[0].due_date}} and {{$debts.0.due_date}}.
Integration preconditions
The sender's integration_id must name an integration in the same account that is both active and verified. Otherwise the call fails before the template is validated, with 404, 400 inactive-integration, or 400 unverified-integration.
Behavior differences to test
API keys and permissions
API keys are not scoped per resource. A key has one type, either manage, use or analytics, chosen at creation and not editable afterwards. manage keys reach both /v1/broadcasts/* and /v1/campaigns/*, so a manage key that works against Broadcasts today needs no change to work against Campaigns. use and analytics keys are rejected with 403 on both.
Dashboard users and other JWT callers are gated separately, by role permissions. Each campaign route checks exactly one:
| Requests | Permission |
|---|---|
GET on campaigns, senders, subscribers, analytics, check_name | campaigns.read |
POST and PUT | campaigns.write |
DELETE /v1/campaigns/{campaign_id} | campaigns.delete |
Grant all three to the roles that currently hold the broadcasts.* permissions.
Session behavior
Broadcasts accepted force_new_sessions and session_timeout per request. Campaigns accept neither. Session timeout is taken from the sender integration's own session_timeout setting, so check that value on every integration you send from. There is no per-request equivalent of force_new_sessions. Confirm that sends group into conversations the way you expect before you cut over.
Status values
Campaigns add canceled to the broadcast set of draft, ready, in_progress, sent, failed. Any client that switches on status needs a branch for it.
Allowed campaign transitions are narrow:
| From | To |
|---|---|
draft | ready |
ready | draft |
in_progress | canceled |
sent | canceled |
failed, canceled | nothing |
Anything else returns 400 campaign-status-invalid.
ready to canceled is not allowed. To cancel a campaign that is scheduled but has not started, set status back to draft, which cancels the queued start, or delete the campaign. canceled is only reachable once the campaign is in_progress or sent.
Subscriber writes after the send starts
Broadcasts rejected bulk-upsert and bulk-remove once the broadcast left draft or ready, with 400 broadcast-status-invalid. Campaigns accept them in every status, and on an in_progress campaign a bulk-upsert sends immediately to the newly created subscribers.
Guard any retry, replay or list-resync job on the campaign's status. A request that previously bounced off a harmless 400 now delivers real messages.
Removing subscribers
The identifier in subscriber_ids changed type. Broadcasts removed by external_id, the phone number or Viber id itself. Campaigns require the server-generated subscriber_id UUID, which you read from GET /v1/campaigns/{campaign_id}/subscribers. Reusing an existing list of phone numbers returns 400.
Response bodies
| Endpoint | Broadcasts returned | Campaigns return |
|---|---|---|
bulk-upsert | subscribers_added, subscribers_updated | created, updated, failed, plus errors[] when rows fail, removed with ?replace=true, and created_subscriber_ids on an in_progress campaign |
bulk-remove | subscribers_removed | removed, failed |
DELETE | the full deleted object | { "campaign_id": "…" } |
Any client reading the old counters starts reading undefined with no error.
Subscriber list filters
?external_id= was a supported filter on broadcast subscribers. The campaign endpoint has no such parameter and silently drops unknown query keys, so a filtered request returns the whole page instead of the row you asked for. Use ?q= with a phone or email, or GET /v1/campaigns/{campaign_id}/subscribers/{subscriber_id}.
sort also changed. Broadcast subscribers allowed external_id and updated; campaign subscribers allow phone, email, updated, last_sent_at, last_delivered_at, last_read_at and last_replied_at.
Recipients per send-now call
POST /v1/broadcasts/send-now accepts up to 5000 recipients per call. POST /v1/campaigns/send-now accepts between 1 and 5. Neither endpoint stores a campaign or broadcast record, so neither send appears in campaign analytics. If you send bulk through send-now, move to the four-call flow above rather than to campaign send-now.
The two request bodies are not interchangeable. Broadcast send-now takes a flat body: the create fields plus subscribers. Campaign send-now takes an envelope with three required keys:
POST /v1/campaigns/send-now
{
"campaign": { /* the POST /v1/campaigns body */ },
"sender": { /* the POST /senders body; transitions are ignored */ },
"subscribers": [ /* 1 to 5 entries, each with phone or email */ ]
}
What does not migrate
Broadcasts could target channel: "viber", where a subscriber's external_id was an opaque Viber id of up to 64 characters rather than a phone number. Campaign subscribers are addressed only by phone or email, and only the mstat-viber integration type is accepted as a Viber campaign sender. A Viber broadcast audience keyed by Viber id has to be re-keyed to real phone numbers before it can move.
Support policy
The Broadcasts API remains available. A removal date has not been set, and it will be announced here with notice before any endpoint is withdrawn.
Broadcasts receive no new Broadcasts-specific functionality. Correctness and security fixes still land, and Broadcasts share the WhatsApp template validator with Campaigns, so improvements there reach both. Every Campaigns-only capability below stays in Campaigns.
What Campaigns add
More channels. A broadcast's channel was only whatsapp or viber. A campaign sender resolves its channel from its integration, across whatsapp, sms, rcs, email, voice and viber.
Multiple senders per campaign, each with its own integration, and so its own channel and template. Senders form one chain: exactly one sender is the entry point, and every other sender must be the target of a transition from another sender. A campaign whose senders have two entry points, or a cycle, fails with invalid-campaign-sender-configuration.
Fallback and follow-up transitions between senders. A transition's condition is either delivery_failed, which moves the subscriber to another sender when a send fails, or no_response, which fires a set number of minutes after delivery if the subscriber has not replied. Where a sender has several fallbacks, they are tried in order, and one is skipped when the subscriber has no contact detail for that sender's channel. Set skip_fallback in a subscriber's metadata to opt that subscriber out of fallbacks entirely.
Next best action, an agentic mode that chooses the follow-up per subscriber. Set is_agentic: true on the campaign and supply nba_config. nba_config is required when is_agentic is true and rejected when it is false. It is independent of the campaign type.
Stopping a running campaign. Set status to canceled on a campaign in in_progress or sent, which also cancels its pending transition jobs. Un-scheduling a campaign that has not started yet is ready to draft, which broadcasts allowed too.
Send windows and an end time. schedule.send_window confines sends to given days and hours, and schedule.end_at stops the campaign at a fixed time. Broadcasts had only scheduled_at.
Per-subscriber control, through the stop and resume endpoints listed above. A broadcast could only be stopped as a whole.
Campaign analytics through GET /v1/campaigns/{campaign_id}/analytics, which reports at campaign level: a funnel, KPI tiles and a daily series. Broadcasts have no analytics endpoint. There is no sender dimension in that payload; per-sender numbers are the subscribers_count, sent_count, delivered_count, failed_count, read_count and replied_count counters on each sender, read through GET /v1/campaigns/{campaign_id}/senders. A broadcast carried the same counters, but only on the broadcast itself.