Broadcasts
Bulk message broadcasts to a segment of contacts across one or more channels (WhatsApp/Telegram/Instagram). The group covers the whole lifecycle: creating a draft, publishing and running it, A/B variants, throughput and send-window control, opt-outs, click tracking, run management, and re-sending to failed recipients. Every endpoint requires an active subscription and the plan's broadcasts feature.
Lifecycle and scheduling
A broadcast can be a draft (is_draft: true) and later launched in one of these ways:
- immediately —
schedule.mode = now; - scheduled —
later+scheduledAt; - recurring — a
recurrencerule.
Lifecycle: draft → publish → run(s); each run tracks its own progress (sent/delivered/read/failed/skipped).
Broadcast status (status): draft → scheduled/active → completed or failed (cancelled on cancellation). For a one-off broadcast (no recurring schedule), failed is set when its run finishes with every recipient either failed or skipped — the same value is accepted by the statuses[] filter on GET /broadcasts. Re-sending to failed recipients (retry-failed) resets such a broadcast back to scheduled.
A/B variants
Per-channel content supports A/B variants (2–3 per channel, content.channels[].variants): a variant is assigned to each recipient deterministically, with a per-variant stats breakdown in stats.byVariant.
Throughput and send window
Throughput is governed by schedule.rateControl (messagesPerMinute, jitter, dailyLimit, skipContactedWithinHours) and by the sendWindow (the smart mode delivers to each recipient during their historically active hour).
Opt-out
Contacts with opted_out_at are always excluded from the audience; an inbound stop-word message (stop/unsubscribe) auto-opts the contact out. Blocked contacts (blocked_at) are excluded just as unconditionally, and — since 2026-09-08 — so are contacts hidden from the working inbox (hidden_from_inbox_at, see POST /v1/contacts/{contact}/hide-from-inbox).
Click tracking
When content.settings.trackClicks = true, links are rewritten to tracking /r/{code} URLs — a public, unauthenticated redirect that records the click and 302-redirects to the original.
Managing runs
Runs can be paused/resumed/cancelled (pending recipients → skipped) and failed recipients can be re-sent (retry-failed).
Since 2026-09-26 the recipient list (GET /broadcasts/{broadcast}/messages) masks identifier (a phone number or username) down to its last four characters (•••0187) when the viewer neither launched the broadcast nor has company-wide sight (an admin, a manager with no channel_access restriction); record ids are unchanged.
Template validation before sending
For content.channels[] entries with type: "template", an approved version of the selected template on that channel is verified before sending starts. The check runs at three entry points: POST /broadcasts (when schedule.mode is "now"), POST /{broadcast}/run, and POST /{broadcast}/publish. If no approved template is found, the request is rejected with 422 and errors.content[0] reads Channel "..." has no approved template "..." (language ru). Approve this template for the channel or pick a channel where it is approved.
Additionally, if a template has a media header (image/video/document), the system verifies that at least one source — a file or URL — is provided. If neither is present, the request is rejected with 422 and errors.content[0] reads: Template "..." has a media header (...) but no file is uploaded. Upload the file in the template settings.
AI copy helper
The AI text helper (POST /broadcasts/assist, generate/improve) shares a 60/hour rate limit with the rest of the AI assist.
Sending to a bare phone number (static segment)
Since 2026-09-09, a static-segment recipient with no linked contact — a bare phone number, audience.members[].identifier (see the segments group) — on a WhatsApp channel (whatsapp_business or personal whatsapp) is no longer sent "around" the CRM: before sending, AISAR creates a contact, a conversation and a thread on that channel, the message is persisted as a regular outbound one and shows up in the Chat, and provider statuses (delivered/read/failed with a reason) are reflected on the broadcast recipient (error_message in GET /{broadcast}/messages) and in the run's stats. The pasted list name (display_name) is assigned only to a newly created contact — an existing contact keeps whatever name it already has. For opted-out (opted_out_at) or blocked (blocked_at) contacts no conversation is created — the recipient is marked skipped. These conversations neither create a deal nor trigger the "conversation opened" automation — a blast to 500 numbers must not spawn 500 deals or 500 automation runs.
WABA preflight check before a run starts
Since 2026-09-09, before each run starts on a WhatsApp Business API channel, AISAR asks Meta for health_status.can_send_message. If the answer is BLOCKED (e.g. a payment-method issue — error code 141006), the run finishes as failed without a single send: the reason (Meta's own error description and code) is recorded in the broadcast's own log (logs[], run_failed event) and in the channel's log under the broadcast.preflight_blocked code (see the channels group, "Send-failure codes in the channel log"). When the Graph API is unreachable or the channel has no stored WABA credentials, the check is silently skipped and the run proceeds as usual (fail-open) — it must never become a new way to lose a broadcast.
Attachments in channel content
Since 2026-09-23 a broadcast attachment (content.channels[].attachments[]) is accepted only as a reference to a file your company uploaded through POST /v1/uploads (see the messages group): send its media_id or the storage_disk/storage_path pair from the upload response. The url and media_asset_id fields are prohibited in the request (422). One content channel carries at most 10 attachments, the same cap as a single chat message. The rules are the same for POST /v1/broadcasts and PATCH /v1/broadcasts/{broadcast} (including its POST alias). Attachment fields:
media_id: the file identifier from thePOST /v1/uploadsresponse (shapedmed_...);storage_disk+storage_path: an alternative tomedia_id, the pair from the same response;id: the attachment identifier within the broadcast (a string of up to 64 characters), used to recognise the attachment on a later save;type:image,video,audio,voice,document,fileorother;nameorfilename,mimeTypeormime_type,sizeBytesorsize: display metadata; an over-long name is truncated rather than rejected.
Every media_id/storage_path is looked up among your company's uploads. Another company's file, a missing file, or a path with no upload record yields 422 with the content.channels.N.attachments.M key in errors and the message Attachment source is invalid or does not belong to the current company. An attachment with no source is also rejected with 422 under the same key (Attachment has no uploaded file: upload it first and send its media_id.), except when an already-saved attachment is re-sent by id (see below).
In the saved content an attachment keeps only media_asset_id and metadata (id, type, name, mimeType, sizeBytes), with no path, disk or media_id; this is also how GET /v1/broadcasts/{broadcast} returns it. Uploads the broadcast references are pinned: their expires_at is reset to null and the scheduled file cleanup no longer removes them, so a draft or a scheduled broadcast does not lose its file before it is sent.
When updating a broadcast, an already-saved attachment can be re-sent by id alone, without media_id/storage_path: it keeps its file. An id the broadcast does not have, with no source, yields 422. A previously saved attachment that never had a file is dropped from content without an error on such a re-save.
At send time the attachment's type and MIME type come from the uploaded file itself, not from the submitted type/mimeType (exception: voice on an audio file stays a voice note); the file name comes from name. If the attachment's file no longer exists in the company when sending, the message is not sent without it: the recipient is marked failed with error_message = "attachment_unavailable" (visible in GET /v1/broadcasts/{broadcast}/messages), and one entry with event: "attachment_unavailable" is added to the broadcast's logs[] per run. Attachments saved before 2026-09-23 with a path instead of media_asset_id are re-checked at send time: a path outside your company's files is dropped and the message goes out without that attachment.
Test send attachments
POST /v1/broadcasts/test takes files directly, in the multipart field attachments[], not as references to uploads: at most 10 files, each up to 10 MB, with the same extensions as POST /v1/uploads (the extension is checked against the MIME type detected from the file contents); otherwise the response is 422. Test send files are stored privately among your company's uploads and live for 24 hours: after that the scheduled file cleanup removes them, and a broadcast does not pin them. A file whose contents the company has already uploaded is not stored again; the existing one is reused. If the same file is later uploaded through POST /v1/uploads, it becomes a regular upload with the regular retention period, and its media_id can be referenced in a broadcast.
Channels that cannot write first
A channel with can_start_conversation: false (currently only Telegram Business, see the "Channels" group) takes no part in broadcasts: it cannot write to a client first. Picking such a channel explicitly in channels.channelIds or content.channels[].channelId when creating or updating a broadcast is refused with 422 and a validation error on the channels.channelIds field (the text lists the ids of the refused channels). The last_interacted mode skips threads of such channels and picks the recipient's latest thread on another channel, if there is one. Switching on allow_broadcasts for such a channel through PATCH /channels/{channel} is not possible either and answers 422.
Endpoints
| Method | Path | Summary |
|---|---|---|
| GET | /v1/broadcasts/metaBroadcast builder metadata (segments, variables, templates) | Broadcast builder metadata (segments, variables, templates) |
| GET | /v1/broadcastsList broadcasts (paginated, filterable by status) | List broadcasts (paginated, filterable by status) |
| GET | /v1/broadcasts/{broadcast}Get a broadcast | Get a broadcast |
| POST | /v1/broadcastsCreate a broadcast (supports is_draft) | Create a broadcast (supports is_draft) |
| PATCH | /v1/broadcasts/{broadcast}Update a broadcast | Update a broadcast |
| POST | /v1/broadcasts/{broadcast}Update a broadcast (POST alias of PATCH) | Update a broadcast (POST alias of PATCH) |
| DELETE | /v1/broadcasts/{broadcast}Delete a broadcast | Delete a broadcast |
| POST | /v1/broadcasts/estimateEstimate broadcast audience size | Estimate broadcast audience size |
| POST | /v1/broadcasts/{broadcast}/duplicateDuplicate a broadcast | Duplicate a broadcast |
| POST | /v1/broadcasts/{broadcast}/publishPublish a draft (launch it or schedule it) | Publish a draft (launch it or schedule it) |
| POST | /v1/broadcasts/{broadcast}/cancelCancel a one-off broadcast | Cancel a one-off broadcast |
| POST | /v1/broadcasts/{broadcast}/runManually trigger a broadcast run | Manually trigger a broadcast run |
| GET | /v1/broadcasts/{broadcast}/messagesRun recipients (paginated, filterable by status) | Run recipients (paginated, filterable by status) |
| GET | /v1/broadcasts/{broadcast}/runsList broadcast runs | List broadcast runs |
| GET | /v1/broadcasts/{broadcast}/runs/{run}Get a specific run | Get a specific run |
| POST | /v1/broadcasts/{broadcast}/pause-recurringPause a recurring schedule | Pause a recurring schedule |
| POST | /v1/broadcasts/{broadcast}/resume-recurringResume a recurring schedule | Resume a recurring schedule |
| POST | /v1/broadcasts/testSend a test message to a specific recipient | Send a test message to a specific recipient |
| POST | /v1/broadcasts/assistAI helper for broadcast copy (generate/improve) | AI helper for broadcast copy (generate/improve) |
| POST | /v1/broadcasts/{broadcast}/runs/{run}/pausePause a run | Pause a run |
| POST | /v1/broadcasts/{broadcast}/runs/{run}/resumeResume a run | Resume a run |
| POST | /v1/broadcasts/{broadcast}/runs/{run}/cancelCancel a run (pending recipients → skipped) | Cancel a run (pending recipients → skipped) |
| POST | /v1/broadcasts/{broadcast}/runs/{run}/retry-failedRe-send to failed recipients only | Re-send to failed recipients only |
Examples
Create and immediately launch a broadcast
Request
curl -X POST https://api.aisar.app/v1/broadcasts \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "February promo",
"is_draft": false,
"audience": { "segmentId": "12" },
"channels": { "mode": "specific", "channelIds": [7] },
"content": {
"channels": [
{ "type": "text", "channelId": 7, "accountType": "whatsapp", "text": "Hi {{first_name}}, 20% off this week!" }
]
},
"schedule": { "mode": "now" }
}'Response
{
"data": {
"id": 88,
"company_id": 1,
"name": "February promo",
"status": "active",
"audience": {
"conditions": [],
"group_logic": "and"
},
"channels": [{ "id": 7, "mode": "specific" }],
"content": [],
"schedule": { "mode": "now", "scheduledAt": null },
"recurring_schedule": null,
"runs": [
{ "id": 201, "broadcast_id": 88, "status": "processing", "total": 340, "sent": 0, "failed": 0, "delivered": 0, "read": 0, "started_at": "2026-03-15T10:00:00.000000Z", "completed_at": null }
],
"created_at": "2026-03-15T10:00:00.000000Z",
"updated_at": "2026-03-15T10:00:00.000000Z"
}
}Estimate broadcast audience
Request
curl -X POST https://api.aisar.app/v1/broadcasts/estimate \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"segment_id": 12,
"channel_ids": [7]
}'Response
{
"data": {
"total_contacts": 340,
"reachable_contacts": 312,
"excluded_contacts": 28,
"estimated_duration_seconds": 312,
"estimated_completion": "2026-03-15T10:05:12.000Z"
}
}Draft with A/B variants, rate control and a send window
Request
curl -X POST https://api.aisar.app/v1/broadcasts \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Spring reactivation",
"is_draft": true,
"audience": { "segmentId": "12" },
"channels": { "mode": "specific", "channelIds": [7] },
"content": {
"channels": [
{
"type": "text",
"channelId": 7,
"accountType": "whatsapp",
"text": "Hi {{first_name}}, we miss you!",
"variants": [
{ "key": "A", "text": "Hi {{first_name}}, we miss you — here is 15% off." },
{ "key": "B", "text": "{{first_name}}, come back for 15% off this week." }
]
}
],
"settings": {
"trackClicks": true,
"includeOptOut": true,
"optOutText": "Reply STOP to unsubscribe"
}
},
"schedule": {
"mode": "later",
"scheduledAt": "2026-04-01T09:00:00Z",
"rateControl": {
"enabled": true,
"messagesPerMinute": 30,
"jitterMinSeconds": 2,
"jitterMaxSeconds": 8,
"dailyLimit": 5000,
"skipContactedWithinHours": 48
},
"sendWindow": {
"enabled": true,
"mode": "smart",
"startTime": "09:00",
"endTime": "20:00",
"timezone": "recipient"
}
}
}'Response
{
"data": {
"id": 89,
"company_id": 1,
"name": "Spring reactivation",
"status": "draft",
"audience": { "conditions": [], "group_logic": "and" },
"channels": [{ "id": 7, "mode": "specific" }],
"content": [],
"schedule": { "mode": "later", "scheduledAt": "2026-04-01T09:00:00.000000Z" },
"recurring_schedule": null,
"runs": [],
"created_at": "2026-03-20T12:00:00.000000Z",
"updated_at": "2026-03-20T12:00:00.000000Z"
}
}Publish a draft (draft → scheduled)
Request
curl -X POST https://api.aisar.app/v1/broadcasts/89/publish \
-H "Authorization: Bearer YOUR_API_TOKEN"Response
{
"data": {
"id": 89,
"status": "scheduled",
"schedule": { "mode": "later", "scheduledAt": "2026-04-01T09:00:00.000000Z" },
"runs": [],
"updated_at": "2026-03-20T12:05:00.000000Z"
}
}Error: no approved template on the channel
Request
curl -X POST https://api.aisar.app/v1/broadcasts/90/run \
-H "Authorization: Bearer YOUR_API_TOKEN"Response
{
"data": {
"message": "Channel \"WhatsApp Main\" has no approved template \"spring_promo\" (language ru). Approve this template for the channel or pick a channel where it is approved.",
"errors": {
"content": [
"Channel \"WhatsApp Main\" has no approved template \"spring_promo\" (language ru). Approve this template for the channel or pick a channel where it is approved."
]
}
}
}Test message before launching
Request
curl -X POST https://api.aisar.app/v1/broadcasts/test \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"channel_id": 7,
"recipient": "+12025550102",
"content": { "type": "text", "text": "Test: 15% off this week!" }
}'Response
{
"data": {
"message": "Test message queued.",
"message_id": "b1f2c3d4-EXAMPLE"
}
}AI helper for broadcast copy
Request
curl -X POST https://api.aisar.app/v1/broadcasts/assist \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"action": "improve",
"text": "hey buy our stuff now big sale",
"locale": "en"
}'Response
{
"data": {
"text": "Big news, {{first_name}} — our best sale of the season is live. Save 20% this week only.",
"action": "improve",
"tokens_used": 128
}
}Run recipients (filtered by failed status)
Request
curl "https://api.aisar.app/v1/broadcasts/89/messages?status=failed&per_page=2" \
-H "Authorization: Bearer YOUR_API_TOKEN"Response
{
"data": [
{
"id": 7001,
"contact_id": 123,
"identifier": "+7700000XXXX",
"status": "failed",
"error_message": "recipient_not_on_whatsapp",
"attempts": 2,
"variant_key": "A",
"delivered_at": null,
"read_at": null,
"replied_at": null,
"clicked_at": null,
"contact": { "id": 123, "display_name": "Aylin Weber", "first_name": "Aylin", "last_name": "Weber" },
"channel": { "id": 7, "name": "WhatsApp Main" }
},
{
"id": 7002,
"contact_id": 124,
"identifier": "+7700000YYYY",
"status": "failed",
"error_message": "message_send_timeout",
"attempts": 3,
"variant_key": "B",
"delivered_at": null,
"read_at": null,
"replied_at": null,
"clicked_at": null,
"contact": { "id": 124, "display_name": "Marco Ricci", "first_name": "Marco", "last_name": "Ricci" },
"channel": { "id": 7, "name": "WhatsApp Main" }
}
],
"meta": { "current_page": 1, "last_page": 1, "per_page": 2, "total": 2 }
}Re-send to failed recipients
Request
curl -X POST https://api.aisar.app/v1/broadcasts/89/runs/201/retry-failed \
-H "Authorization: Bearer YOUR_API_TOKEN"Response
{
"data": {
"id": 201,
"broadcast_id": 89,
"status": "processing",
"total": 340,
"sent": 300,
"failed": 0,
"delivered": 280,
"read": 190,
"started_at": "2026-04-01T09:00:00.000000Z",
"completed_at": null
}
}Run stopped by the preflight check (blocked WABA)
Request
curl -X GET https://api.aisar.app/v1/broadcasts/90 \
-H "Authorization: Bearer YOUR_API_TOKEN"Response
{
"data": {
"id": 90,
"status": "failed",
"logs": [
{
"level": "warning",
"event": "run_failed",
"message": "WhatsApp Business account \"WhatsApp Main\" cannot send messages: There has been an error processing your payment method. (Meta error 131042)",
"timestamp": "2026-09-09T08:15:44.000000Z"
}
],
"runs": [
{ "id": 305, "broadcast_id": 90, "status": "failed", "total": 0, "sent": 0, "failed": 0, "delivered": 0, "read": 0, "started_at": "2026-09-09T08:15:44.000000Z", "completed_at": "2026-09-09T08:15:44.000000Z" }
],
"updated_at": "2026-09-09T08:15:44.000000Z"
}
}Broadcast with an attachment from POST /v1/uploads
Request
curl -X POST https://api.aisar.app/v1/broadcasts \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Price list",
"is_draft": true,
"audience": { "segmentId": "12" },
"channels": { "mode": "specific", "channelIds": [7] },
"content": {
"channels": [
{
"type": "text",
"channelId": 7,
"accountType": "whatsapp",
"text": "Hi {{first_name}}, here is our new price list.",
"attachments": [
{ "id": "att_1", "type": "document", "name": "price-list.pdf", "media_id": "med_9f3a2b7c" }
]
}
]
},
"schedule": { "mode": "later", "scheduledAt": null }
}'Response
{
"data": {
"id": 91,
"company_id": 1,
"name": "Price list",
"status": "draft",
"content": {
"channels": [
{
"type": "text",
"channelId": 7,
"accountType": "whatsapp",
"text": "Hi {{first_name}}, here is our new price list.",
"attachments": [
{ "id": "att_1", "type": "document", "name": "price-list.pdf", "media_asset_id": 5120 }
]
}
]
},
"runs": [],
"created_at": "2026-09-23T10:00:00.000000Z",
"updated_at": "2026-09-23T10:00:00.000000Z"
}
}Broadcast builder metadata
Request
curl "https://api.aisar.app/v1/broadcasts/meta" \
-H "Authorization: Bearer YOUR_API_TOKEN"Response
{
"data": {
"segments": [
{ "id": "12", "name": "Active customers", "size": 340, "channels": [7] }
],
"variables": [
{ "key": "first_name", "displayName": "First name" },
{ "key": "order_number", "displayName": "Order number" }
],
"templates": [
{
"id": "15",
"name": "Appointment reminder",
"category": "utility",
"content": "Здравствуйте, {{first_name}}! Напоминаем о записи {{appointment_at}}.",
"channel_type": "whatsapp_business",
"status": "published",
"variables": ["first_name", "appointment_at"],
"header_enabled": true,
"header_type": "text",
"header_media_url": null,
"header_media": null
},
{
"id": "20",
"name": "Promotion with image",
"category": "marketing",
"content": "Здравствуйте, {{first_name}}! Ознакомьтесь с нашим новым каталогом.",
"channel_type": "whatsapp_business",
"status": "published",
"variables": ["first_name"],
"header_enabled": true,
"header_type": "image",
"header_media_url": null,
"header_media": {
"media_id": "med_01arc7z0z0000000001234567",
"filename": "catalog-march-2026.jpg",
"mime_type": "image/jpeg",
"size": 2457600,
"preview_url": "https://api.aisar.app/v1/media/template-header/01ARC7Z0Z0000000001234567?company=42&expires=1791180000&signature=…"
}
}
]
}
}Error: the attachment file does not belong to the company
Request
curl -X PATCH https://api.aisar.app/v1/broadcasts/91 \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"content": {
"channels": [
{
"type": "text",
"channelId": 7,
"accountType": "whatsapp",
"text": "Hi {{first_name}}, here is our new price list.",
"attachments": [
{ "id": "att_2", "type": "document", "name": "report.pdf", "storage_disk": "local", "storage_path": "99/attachments/0b7e5d1a-3c2f-4a8e-9d10-2e3f4a5b6c7d.pdf" }
]
}
]
}
}'Response
{
"data": {
"message": "Attachment source is invalid or does not belong to the current company.",
"errors": {
"content.channels.0.attachments.0": [
"Attachment source is invalid or does not belong to the current company."
]
}
}
}