Campaigns
Create, preview, send, pause and track a message to a whole audience.
Campaigns
A campaign sends one message to many of your opted-in contacts. The API gives you the same lifecycle the dashboard has: create a draft with its audience and content in one call, ask who it would actually reach, send now or schedule for later, watch recipients settle, pause or cancel mid-flight, and delete a campaign to free the plan slot it holds.
Everything here runs through the same code as the dashboard. Consent, suppression lists, plan limits and send pacing are enforced identically — there is no API path around them.
Scopes: campaigns:read, campaigns:write (draft lifecycle), and
campaigns:send — schedule, send-now, pause, resume, cancel. Send is a
separate grant because one call fires messages at an entire audience; see
Authentication.
Create a campaign
POST /api/v1/campaigns
{
"name": "July wholesale offer",
"audience_definition": { "type": "list", "list_id": "3f8a…" },
"content": { "body": "Our July rates are live — reply ORDER for the catalogue." }
}
| Field | Required | Notes |
|---|---|---|
name |
yes | Internal name, up to 160 characters — recipients never see it |
audience_definition |
no* | Who receives it — see below. *Required before scheduling |
content |
no* | What they receive — see below. *Required before scheduling |
Returns 201 with the campaign in draft status. Nothing is sent until you
call schedule.
The audience
One of three shapes:
{ "type": "contacts", "contact_ids": ["d1c4…", "9e0b…"] }
{ "type": "list", "list_id": "3f8a…" }
{ "type": "segment", "segment_id": "77aa…" }
contacts takes 1–50,000 unique contact ids. Lists and segments are the ones
in your dashboard. Whatever the shape, only opted-in, non-suppressed
contacts are messaged — the rest are counted as skipped, never contacted.
The content
Your own words, a template, or words plus files:
{ "body": "Plain message" }
{ "template_id": "9b2f…" }
{ "body": "See attached", "media": { "attachments": [{ "media_id": "5c1d…" }] } }
Send either body or template_id, not both. A template is resolved to its
words and its file at scheduling time — a later edit to the template cannot
change what an approved campaign says. Per-recipient variables like {name}
are filled at dispatch, per contact (see Templates).
Who would this reach?
POST /api/v1/campaigns/audience/preview
{ "audience_definition": { "type": "list", "list_id": "3f8a…" } }
{
"data": { "matching": 184, "reachable": 171, "skipped": 13, "sample": ["Ayesha Khan", "Bilal Traders"] }
}
Nothing is saved. reachable is who will actually be messaged; skipped is
who the audience covers but consent or suppression removes. The count is
produced by the same query dispatch uses, and it can only go down between
preview and send — anyone who opts out in the meantime is skipped
automatically.
Send now, or schedule
POST /api/v1/campaigns/{id}/schedule
Send immediately:
{ "connection_id": "b7e2…", "send_now": true }
Or at a wall-clock time in your workspace's timezone (not the server's, not your machine's):
{ "connection_id": "b7e2…", "scheduled_at_local": "2026-08-01T09:00", "timezone": "Asia/Karachi" }
| Field | Notes |
|---|---|
connection_id |
Which of your connected numbers sends. GET /workspace shows them |
send_now |
true to start immediately |
scheduled_at_local |
YYYY-MM-DDTHH:mm, must be in the future |
timezone |
Must match the workspace's saved timezone — a mismatch is refused rather than silently reinterpreted |
Both shapes validate the audience and content at this moment and refuse with
422 if the audience has no reachable recipient, the content is empty, or the
plan's campaign allowance is exhausted. Requires campaigns:send.
Watch it run
GET /api/v1/campaigns/{id}
GET /api/v1/campaigns/{id}/recipients
The campaign carries live counters — recipient_count, sent_count,
failed_count, status (draft, scheduled, running, paused,
completed, cancelled, failed). Recipients are paginated, one row per
person, each with a status and, on failure, a failure_code.
Prefer webhooks to polling: subscribe to campaign.started,
campaign.completed and campaign.failed and the final counters come to you
(see Webhook events).
How fast it sends
A campaign does not fire every message at once. These are session-based numbers,
and a burst is exactly what spam detection looks for, so each number sends within
its sending policy: a per-number hourly and daily cap and a small random gap
between messages. A large audience is therefore spread out — released steadily
and carried into the next hour or day when a cap is reached — rather than sent in
one rush. The policy is conservative by default and fully editable in your
workspace settings, per workspace and per number. This is why a big campaign shows
running for a while: it is pacing itself on purpose to keep the number healthy.
See Sending policy for what each control does.
The test batch before a big send
Before a campaign sends to everyone, it runs a small canary: it sends to a handful of recipients first, waits, and checks how that batch went — how many were delivered or failed, and the sending number's health. Only if the test batch looks safe does the rest of the campaign go out. If it looks risky, the campaign pauses itself instead of burning the number on the full send, and tells you why (for example, "the number's health is risky" or "60% of the test batch failed").
While this is happening the campaign shows it is checking a test batch; a
canary that pauses the campaign leaves it in paused, exactly like a manual
pause. Look at the number, then resume when you are ready — resuming is your
go-ahead, so the campaign continues from where it stopped without re-running the
test. A campaign.paused webhook fires with the reason.
The canary only runs when the audience is larger than the test batch (there is nothing to protect otherwise). Its size, how long it watches, how much failure it tolerates, and which health states stop it are all editable in Settings → Sending policy; it can be turned off entirely. The defaults are conservative.
The message risk check
As you write a campaign, its message is scored for the things WhatsApp's spam detection reacts to — several links, a link shortener, shouting in capitals, a phone number in the text, an over-long message — and the composer shows a small risk hint (amber for "watch", red for "higher risk") with the reasons. It is purely advisory: it never blocks a send, it just gives you the chance to soften the message first. What counts, and at what score, is editable in Settings → Sending policy.
Not messaging one person too often
Recipient safety is an optional guard, off until you turn it on in Settings → Sending policy. When on, it holds back a marketing send to a contact who has already been messaged within a window you set (so two campaigns in a day do not double up on the same people), and it holds back a link or media on the very first message to a brand-new contact — the riskiest send there is. A held recipient shows as skipped (with the reason) on the campaign's recipient list; it is not a failure, and the contact is untouched. Inbox replies and transactional API sends are never affected.
Pause, resume, cancel
POST /api/v1/campaigns/{id}/pause
POST /api/v1/campaigns/{id}/resume
POST /api/v1/campaigns/{id}/cancel
Pause stops the next batch, not the one already in flight. Resume picks up
exactly where it left off — nobody is messaged twice. Cancel is permanent:
recipients still waiting are marked cancelled, and what was already sent stays
sent. All three require campaigns:send.
Update and delete
PUT /api/v1/campaigns/{id}
DELETE /api/v1/campaigns/{id}
Only a draft or scheduled campaign can be edited. Deleting frees the plan
slot the campaign occupies — that is what to do when creation answers 422
with the allowance message. A campaign that is running cannot be deleted;
cancel it first. Messages already sent are never recalled by any of this; only
the record-keeping changes.