Skip to content
Contents
Sending and receiving

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.

FieldDescription
idThe campaign's public id.
statusOne of the nine states below.
channel_id, template_idThe channel it sends from and the template it sends.
template_slug, template_languageThe template's slug and language, copied onto the campaign for reference.
audienceThe selectors exactly as they were saved. They are resolved at launch, not on save.
variables, variable_mappingFixed values, plus a mapping from a template placeholder to a contact field.
schedule_atWhen it should start, or null to start as soon as it can.
total_recipientsHow many recipients were materialised. 0 until the worker has finished.
excludedWho was dropped, by reason: opted_out, no_consent, invalid_number.
materialised_atWhen the audience was frozen. Null means it has not been yet.
failure_reasonWhy 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.

ScopeUsed for
campaigns.readList, read, recipients, stats, audience preview and the pre-launch check.
campaigns.manageCreate, update, delete, launch, pause, resume, cancel and retry.

Endpoints

GET/v1/campaignsPOST/v1/campaignsGET/v1/campaigns/{campaignId}PATCH/v1/campaigns/{campaignId}DELETE/v1/campaigns/{campaignId}POST/v1/campaigns/audience-previewPOST/v1/campaigns/{campaignId}/validatePOST/v1/campaigns/{campaignId}/launchPOST/v1/campaigns/{campaignId}/pausePOST/v1/campaigns/{campaignId}/resumePOST/v1/campaigns/{campaignId}/cancelPOST/v1/campaigns/{campaignId}/retry-failedGET/v1/campaigns/{campaignId}/recipientsGET/v1/campaigns/{campaignId}/stats
curl
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.

FieldDescription
group_idsContact group ids.
tag_idsContact tag ids.
contact_idsExplicit contact ids.
phone_numbersRaw E.164 numbers. Ones matching no contact come back in unknown_numbers.
allEvery 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
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

StateDescription
draftCreated and editable. Nothing is sent.
validatingLaunched. A worker is resolving the audience.
readyThe audience is materialised and sending is about to begin.
scheduledMaterialised, waiting for schedule_at.
runningHanding recipients to the queue.
pausedStopped handing over more recipients.
completedEvery recipient has been dealt with.
cancelledStopped for good; pending recipients are cancelled.
failedThe 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.
  • …/pause stops 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.
  • …/resume continues where it stopped. Nobody already sent to is sent to twice.
  • …/retry-failed re-queues the recipients that failed and answers the campaign plus a retried count.

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
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
# }
FieldDescription
recipients.pendingMaterialised and not yet handed to the queue.
recipients.enqueuedHanded to the queue; the message now has its own state.
recipients.skippedDropped before sending. skip_reason on the recipient says why.
recipients.failedSending 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

CodeHTTPWhen it happens
CAMPAIGN_NOT_FOUND404No campaign with that id in this workspace.
CAMPAIGN_INVALID_STATE409The action is not available from the campaign's current status — pausing a draft, launching a completed campaign.
CAMPAIGN_CHANNEL_NOT_ELIGIBLE409The channel cannot run a campaign: not connected, or not eligible.
CAMPAIGN_PAUSED409A send was attempted while the campaign is paused.
CAMPAIGN_AUDIENCE_EMPTY422The audience resolved to nobody who may receive a marketing message.
ENTITLEMENT_LIMIT_REACHED409The 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.