Skip to main content

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/broadcastsPOST /v1/campaigns then POST /v1/campaigns/{campaign_id}/senders
GET /v1/broadcastsGET /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}/subscribersGET /v1/campaigns/{campaign_id}/subscribers
PUT /v1/broadcasts/{broadcast_id}/subscribers/bulk-upsertPUT /v1/campaigns/{campaign_id}/subscribers/bulk-upsert
PUT /v1/broadcasts/{broadcast_id}/subscribers/bulk-removePUT /v1/campaigns/{campaign_id}/subscribers/bulk-remove
PUT /v1/broadcasts/verify-subscribersPUT /v1/campaigns/verify-subscribers
POST /v1/broadcasts/send-nowPOST /v1/campaigns/send-now
GET /v1/broadcasts/check_nameGET /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:

EndpointPurpose
GET, POST on /v1/campaigns/{campaign_id}/sendersList 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}/stopStop sending to one subscriber
POST /v1/campaigns/{campaign_id}/subscribers/{subscriber_id}/resumeResume one subscriber
PUT /v1/campaigns/{campaign_id}/subscribers/verifyValidate a subscriber payload before upserting it
GET /v1/campaigns/{campaign_id}/analyticsCampaign analytics
Sender verbs are on the item path

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 fieldCampaign equivalentWhat to do
channelnoneRemove it. The channel is resolved from the sender's integration_id.
integration_idintegration_id on the senderMove it out of the campaign body and into the sender body. Required there.
actionstemplate on the senderactions 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 subscriberphone or emailSplit the identifier by kind. At least one of the two is required. phone is normalized to E.164, so send it in E.164.
contextsubscribers_contextRename. 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_atschedule.start_atMove 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.
nonetypeRequired. Use marketing, sales or utility.
noneis_agentic, nba_configOptional. See What Campaigns add.
force_new_sessionsnoneSee Session behavior.
session_timeoutnoneSee Session behavior.

schedule also accepts end_at (epoch milliseconds, must be after start_at) and a send_window.

A fourth campaign type exists

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.debt is no longer accepted. Use context.debts[].
  • context.contact_id, alternate_phones and alternate_emails are type-checked, so values a broadcast accepted as free-form can now return 400.
  • Broadcast metadata accepted only source and silently discarded everything else. Campaign metadata also accepts skip_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.

Broadcasts did not require the final call

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 typeTemplate shape
WhatsAppid, name and language required, plus the optional components array. Identical to a broadcast actions[] element.
SMS, RCS, voicetext
Emailsubject 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:

RequestsPermission
GET on campaigns, senders, subscribers, analytics, check_namecampaigns.read
POST and PUTcampaigns.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:

FromTo
draftready
readydraft
in_progresscanceled
sentcanceled
failed, cancelednothing

Anything else returns 400 campaign-status-invalid.

Cancelling a scheduled campaign

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

EndpointBroadcasts returnedCampaigns return
bulk-upsertsubscribers_added, subscribers_updatedcreated, updated, failed, plus errors[] when rows fail, removed with ?replace=true, and created_subscriber_ids on an in_progress campaign
bulk-removesubscribers_removedremoved, failed
DELETEthe 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.