Campaigns
A campaign sends one approved template to many contacts. It is created as a draft, priced against its audience before anything is sent, and then handed to a worker — so launching is an instruction, not the send itself.
The campaign
A campaign names one channel_id and one template_id, and the template carries the message: there is no free-text campaign.
| Field | Description |
|---|---|
| id | The campaign's public id. |
| status | One of the nine states below. |
| channel_id, template_id | The channel it sends from and the template it sends. |
| template_slug, template_language | The template's slug and language, copied onto the campaign for reference. |
| audience | The selectors exactly as they were saved. They are resolved at launch, not on save. |
| variables, variable_mapping | Fixed values, plus a mapping from a template placeholder to a contact field. |
| schedule_at | When it should start, or null to start as soon as it can. |
| total_recipients | How many recipients were materialised. 0 until the worker has finished. |
| excluded | Who was dropped, by reason: opted_out, no_consent, invalid_number. |
| materialised_at | When the audience was frozen. Null means it has not been yet. |
| failure_reason | Why the campaign failed, when it did. |
Authentication and scopes
Both credentials work here: an API key, or a user access token carrying X-Workspace-Id. Authentication covers the difference.
| Scope | Used for |
|---|---|
| campaigns.read | List, read, recipients, stats, audience preview and the pre-launch check. |
| campaigns.manage | Create, update, delete, launch, pause, resume, cancel and retry. |
Endpoints
curl -X POST https://whats.azzamkh.sa/api/v1/campaigns \
-H "Authorization: Bearer $WA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "August reorder reminder",
"channel_id": "ch_...",
"template_id": "tpl_...",
"audience": { "group_ids": ["grp_..."] },
"variable_mapping": { "1": "contact.first_name" }
}'
# 201 Created — a DRAFT. Nothing is sent until it is launched.
# {
# "id": "cmp_...",
# "status": "draft",
# "template_slug": "reorder_reminder",
# "template_language": "ar",
# "total_recipients": 0,
# "excluded": { "opted_out": 0, "no_consent": 0, "invalid_number": 0 },
# "materialised_at": null,
# ...
# }Audience
Selectors are additive: a contact matched by any of them is in. audience is stored as given and resolved only at launch, so somebody added to a group afterwards is not messaged by a campaign already running.
| Field | Description |
|---|---|
| group_ids | Contact group ids. |
| tag_ids | Contact tag ids. |
| contact_ids | Explicit contact ids. |
| phone_numbers | Raw E.164 numbers. Ones matching no contact come back in unknown_numbers. |
| all | Every contact in the workspace. Resolve the size of that before you send it. |
Consent is explicit, and strict
A campaign reaches only a contact whose opt_in_status is opted_in. unknown is not consent. The gap between matched and eligible is the number of people the campaign will NOT message, and it belongs in front of whoever presses launch rather than in the report afterwards.
Preview and the pre-launch check
POST /v1/campaigns/audience-preview prices an audience before a campaign exists; …/validate does the same for a saved one and adds ready and issues. Both accept validate_numbers, which samples the numbers against WhatsApp — see number validation. Both are limited to 60 requests a minute per workspace.
curl -X POST https://whats.azzamkh.sa/api/v1/campaigns/cmp_.../validate \
-H "Authorization: Bearer $WA_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "sample_size": 5, "validate_numbers": false }'
# 200 OK
# {
# "campaign_id": "cmp_...",
# "matched": 1240,
# "eligible": 861,
# "ineligible": {
# "opted_out": 12, "no_consent": 361, "contact_deleted": 0,
# "invalid_number": 4, "duplicate": 2
# },
# "ready": true,
# "issues": [],
# "unknown_numbers": [],
# "unknown_selectors": [],
# "sample": [
# { "contact_id": "cnt_...", "phone_e164": "+9665XXXXXXXX", "display_name": "..." }
# ]
# }
# matched - eligible is 379 people this campaign will NOT message.Lifecycle
| State | Description |
|---|---|
| draft | Created and editable. Nothing is sent. |
| validating | Launched. A worker is resolving the audience. |
| ready | The audience is materialised and sending is about to begin. |
| scheduled | Materialised, waiting for schedule_at. |
| running | Handing recipients to the queue. |
| paused | Stopped handing over more recipients. |
| completed | Every recipient has been dealt with. |
| cancelled | Stopped for good; pending recipients are cancelled. |
| failed | The campaign could not run. failure_reason says why. |
Launch answers validating, not running
POST …/launch returns at once with status: "validating", total_recipients: 0 and materialised_at: null; a worker materialises the audience afterwards. Any progress drawn before that finishes reads zero of zero and looks like a broken send. Poll GET /v1/campaigns/cmp_... until materialised_at is set.
- The audience is frozen once, at launch. Editing a group afterwards changes nothing about a campaign already launched.
…/pausestops the campaign handing over more recipients. It cannot recall a batch already queued, so up to one dispatch batch still sends after a pause — say so in the confirmation.…/resumecontinues where it stopped. Nobody already sent to is sent to twice.…/retry-failedre-queues the recipients that failed and answers the campaign plus aretriedcount.
Progress
GET …/stats is derived at read time from the underlying messages, counted into DISJOINT buckets by current state. The cumulative funnel in analytics answers a different question, so the two do not reconcile — that is deliberate, not drift.
curl -X POST https://whats.azzamkh.sa/api/v1/campaigns/cmp_.../launch \
-H "Authorization: Bearer $WA_API_KEY" \
-H "Content-Type: application/json" \
-d '{}'
# 200 OK — "validating", NOT "running".
# { "id": "cmp_...", "status": "validating", "total_recipients": 0, ... }
# Poll until materialised_at is set, then read progress:
curl https://whats.azzamkh.sa/api/v1/campaigns/cmp_.../stats \
-H "Authorization: Bearer $WA_API_KEY"
# 200 OK — disjoint buckets by CURRENT state, derived at read time.
# {
# "campaign_id": "cmp_...",
# "status": "running",
# "total_recipients": 861,
# "recipients": {
# "pending": 402, "enqueued": 455, "failed": 3, "skipped": 1,
# "cancelled": 0
# },
# "messages": {
# "queued": 12, "sent": 431, "delivered": 388, "read": 106, "failed": 3
# },
# "excluded": { "opted_out": 12, "no_consent": 361, "invalid_number": 4 },
# "started_at": "2026-08-14T09:00:00.000Z",
# "completed_at": null
# }| Field | Description |
|---|---|
| recipients.pending | Materialised and not yet handed to the queue. |
| recipients.enqueued | Handed to the queue; the message now has its own state. |
| recipients.skipped | Dropped before sending. skip_reason on the recipient says why. |
| recipients.failed | Sending was attempted and failed. error_code carries the code. |
| messages.* | Message-state counts: queued, sent, delivered, read, failed. |
| excluded.* | Who never became a recipient at all, by reason. |
GET /v1/campaigns carries no counters at all. Progress lives only in …/stats — one request per campaign, and there is no list field for it.
Errors that matter here
| Code | HTTP | When it happens |
|---|---|---|
| CAMPAIGN_NOT_FOUND | 404 | No campaign with that id in this workspace. |
| CAMPAIGN_INVALID_STATE | 409 | The action is not available from the campaign's current status — pausing a draft, launching a completed campaign. |
| CAMPAIGN_CHANNEL_NOT_ELIGIBLE | 409 | The channel cannot run a campaign: not connected, or not eligible. |
| CAMPAIGN_PAUSED | 409 | A send was attempted while the campaign is paused. |
| CAMPAIGN_AUDIENCE_EMPTY | 422 | The audience resolved to nobody who may receive a marketing message. |
| ENTITLEMENT_LIMIT_REACHED | 409 | The plan does not include campaigns, or a limit is reached. This is an upgrade state, not an error toast. |
Every code, its HTTP status and its default message are on the errors page.