Skip to content

Send a campaign to a tag

A campaign sends one approved template to many contacts. With the API you choose the recipients by tag: everyone who carries at least one of the tags you name. The campaign can start now, or at a time you choose up to 30 days ahead.

A campaign created with the API is the same as one created with Send now or Schedule in the console, and it appears in the console too.

You need:

  • An API key with the send scope (or full). Reading campaigns needs only read.
  • A verified SanadWA account. Until your account is verified, creating a campaign answers 403 kyc_required. Upload your ID or commercial registration in the console under Settings → Verification & privacy.
  • Contacts with a tag. See Import contacts and tag them.
  • An approved template, and the phone_number_id of the number to send from. See steps 1 and 2 of Send a template message.
  • Enough credits for every recipient. See Check your balance.

The examples read the key from the environment variable SANADWA_API_KEY.

Every contact in your account that has at least one of the tags, except:

  • contacts who opted out of marketing messages,
  • contacts that are paused after a WhatsApp delivery problem,
  • contacts whose phone number is not a valid WhatsApp number.

These are left out of every campaign, whatever the template’s category. If nobody is left, the campaign is not created (see the errors below).

Send POST /campaigns. The Idempotency-Key header is required here. Create a new unique key for each new campaign and store it. If the request times out, send it again with the same key: one key never creates or sends a second campaign. See Idempotency.

Field Required Meaning
name Yes A name you will recognise in the console, 1 to 255 characters.
phone_number_id Yes The number to send from, from GET /phone-numbers. Digits only.
template.name, template.language Yes An approved template of that number’s WhatsApp Business Account.
audience.tags Yes 1 to 20 tag names. Each must exist in your tag list.
template_variables If the template has body variables One entry for each body variable, see below.
send_at No When to start, as an ISO 8601 date-time with a time zone, for example 2026-12-01T09:00:00+03:00. Must be in the future and at most 30 days ahead. Leave it out to start now.
header_media_url If the template has an image, video or document header An https link to the file.
tracked_destination_url Only for templates with click-tracked buttons The link the button opens, when the template has no saved destination.

template_variables fills the template’s body variables ({{1}}, {{2}}, …). Each entry has an index and a source:

source Value used for each recipient
contact.first_name The contact’s first name.
contact.last_name The contact’s last name.
contact.phone The contact’s phone number.
contact.email The contact’s email.
custom The same text for everyone, given in value (1 to 1024 characters).

If a value is empty for a recipient (for example a contact with no first name), that recipient’s message is not sent and is counted as canceled.

Terminal window
curl https://api.sanadwa.com/v1/campaigns \
-H "Authorization: Bearer $SANADWA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 9b2f7c1e-0d4a-4e8b-a6c3-5f1e2d7b8a90" \
-d '{
"name": "Winter offer for VIP customers",
"phone_number_id": "100000000000001",
"template": { "name": "winter_offer", "language": "en" },
"audience": { "tags": ["VIP"] },
"template_variables": [
{ "index": 1, "source": "contact.first_name" },
{ "index": 2, "source": "custom", "value": "WINTER20" }
],
"send_at": "2026-12-01T09:00:00+03:00"
}'

Before anything is created, the API checks the account, the number, the template, the variables, the tags, the audience and your credits. If any check fails, nothing is created and nothing is sent. If all pass, you get 201 with the campaign:

{
"success": true,
"data": {
"id": "312",
"name": "Winter offer for VIP customers",
"status": "scheduled",
"pause_reason": null,
"phone_number_id": "100000000000001",
"template": { "name": "winter_offer", "language": "en" },
"audience": { "type": "tags", "tags": ["VIP"] },
"start_at": "2026-12-01T06:00:00.000Z",
"created_at": "2026-11-11T08:00:00.000Z",
"updated_at": "2026-11-11T08:00:00.000Z",
"created_via": "api",
"counts": {
"total": 250, "queued": 250, "sending": 0, "sent": 0,
"delivered": 0, "read": 0, "failed": 0, "canceled": 0
},
"clicks": { "tracked": 0, "clicked": 0 }
}
}

Store the id. The answer comes as soon as the campaign is accepted; the messages are sent in the background. A campaign without send_at starts with status active.

Your credits must cover every recipient when you create the campaign. If they do not, you get 402 insufficient_credits and nothing is created. details says how many credits are needed, how many remaining, and the shortfall:

{
"success": false,
"code": "insufficient_credits",
"message": "Your account does not have enough credits for this audience, so the campaign was not created. Buy credits in the SanadWA console, then retry.",
"details": { "needed": 250, "remaining": 120, "shortfall": 130 }
}

A scheduled campaign checks its audience and your credits again when it starts. Credits are not set aside when you create the campaign: each message uses credits when it is sent.

Code HTTP What to do
idempotency_key_required 400 Send an Idempotency-Key header.
validation_error 400 A field is missing or wrong; message names it. Also for a send_at in the past or more than 30 days ahead.
template_variables_invalid 400 Give exactly one entry for each body variable of the template, in the template’s order.
header_media_required 400 The template has a media header: send header_media_url.
header_media_too_large 400 The file at header_media_url is too large for WhatsApp.
tracked_destination_required, tracked_destination_invalid, tracked_destination_not_applicable 400 Fix or remove tracked_destination_url as message says.
campaign_invalid 400 Another campaign check failed; message explains it.
insufficient_credits, subscription_lapsed 402 Buy credits or renew, then retry with the same key.
kyc_required 403 Verify your account in the console.
tracked_links_disabled 403 The template has click-tracked buttons, which your account cannot use. Choose another template.
phone_not_found 404 Use a phone_number_id from GET /phone-numbers.
template_not_found 404 No template with this name and language for this number.
tag_not_found 404 A tag does not exist; details.tags lists them.
template_not_approved 409 The template is not approved; details.template_status gives its status.
idempotency_conflict 409 This key already created a different campaign. Use a new key.
idempotency_in_progress 409 The same request is still running. Retry with the same key after Retry-After.
audience_empty 422 No contact has any of these tags.
audience_all_excluded 422 Contacts have these tags, but all are excluded (see Who receives the campaign). details.contacts_with_tags gives how many have the tags.
audience_too_large 422 Split the audience into smaller campaigns.
rate_limited 429 Wait Retry-After seconds. Creating campaigns is limited to 5 requests per minute for each API key.
balance_check_failed, campaign_create_timeout 503 Nothing was created. Retry with the same key after Retry-After seconds.

Read the campaign with GET /campaigns/{id}:

Terminal window
curl https://api.sanadwa.com/v1/campaigns/312 \
-H "Authorization: Bearer $SANADWA_API_KEY"
status Meaning
scheduled Waiting for start_at. The recipients are already chosen; they and your credits are checked again when it starts.
delayed start_at has passed, and starting is being retried after a temporary problem.
start_failed The scheduled start could not happen in time. Nothing was sent. Open the campaign in the console.
active Sending. Messages go out gradually, and counts changes as they do.
paused Sending is stopped. pause_reason says why. Messages not sent yet stay queued.
completed Every message has been handed to WhatsApp or has ended. Delivery and read updates can still arrive afterwards.
draft Saved in the console but not started. Campaigns created with the API are never drafts.
failed The campaign stopped and will not send more. Check it in the console.
pause_reason Meaning What to do
start_failed A scheduled start did not complete. Check the campaign in the console.
event_cancelled The campaign was scheduled around an event in the console, and the event was cancelled. Its unsent messages were canceled. Nothing. It cannot be resumed: POST /campaigns/{id}/resume answers 409 campaign_not_paused.
insufficient_credits Sending stopped because your credits ran out, or the subscription that covered sending ended. Buy credits, then resume. See When credits run out.
balance_check_failed Sending stopped because your credit balance could not be checked at that moment. Nothing was charged for the messages still queued. Resume. Resuming checks the balance again.
paused_by_user None of the above: someone paused it, in the console or with POST /campaigns/{id}/pause. Resume when you want it to continue.

pause_reason is null when the campaign is not paused. If more than one reason fits, the one higher in the table is shown. Between insufficient_credits and balance_check_failed, the more recent one is shown.

counts has one number per message state. Every recipient is one message:

Count Meaning
total All messages of the campaign.
queued Waiting to be sent.
sending Being handed to WhatsApp now.
sent Accepted by WhatsApp, not yet reported delivered.
delivered Delivered to the phone, not yet read.
read Read by the recipient.
failed Not delivered.
canceled Not sent on purpose, for example because a template variable was empty for that recipient.

sent, delivered and read change as WhatsApp reports on each message. Reports can come late, for example when a phone is switched off, so the numbers can still change after the campaign is completed.

How often to check: every GET counts toward your rate limit (60 per minute by default). Checking once a minute is plenty for a status page. GET /campaigns/{id} also returns clicks (tracked and clicked), which matters only for templates with click-tracked buttons.

GET /campaigns lists all your WhatsApp campaigns, newest first, including those created in the console (created_via is console). It is paginated, see Pagination, and does not include clicks.

POST /campaigns/{id}/pause stops sending, like the Pause button in the console. Only an active campaign can be paused. Messages already handed to WhatsApp are not recalled; the rest stay queued.

POST /campaigns/{id}/resume continues sending. Only a paused campaign can be resumed. Your credits must cover every message the campaign still has to send, or it stays paused and you get 402 insufficient_credits (with details).

Terminal window
curl -X POST https://api.sanadwa.com/v1/campaigns/312/pause \
-H "Authorization: Bearer $SANADWA_API_KEY"
curl -X POST https://api.sanadwa.com/v1/campaigns/312/resume \
-H "Authorization: Bearer $SANADWA_API_KEY"

Both answer 200 with the campaign after the change. Errors:

Code HTTP Meaning
campaign_not_found 404 No campaign with this id in your account.
campaign_not_active 409 Pause: the campaign is not active. details.status gives its status.
campaign_not_paused 409 Resume: the campaign is not paused. details.status gives its status.
campaign_cannot_resume 409 Resume: the campaign cannot continue; message says why.
campaign_busy 409 Another change to this campaign is running. Retry after Retry-After seconds.
insufficient_credits, subscription_lapsed 402 Resume: not enough credits for the rest of the campaign. It stays paused.
balance_check_failed, campaign_change_timeout 503 Nothing changed. Retry after Retry-After seconds.

A scheduled campaign cannot be paused, and the API cannot cancel or delete a campaign. To stop a scheduled campaign before it starts, open it in the console.

If your balance runs out while a campaign is sending:

  1. SanadWA stops sending. The campaign’s status becomes paused with pause_reason insufficient_credits.
  2. The messages not sent yet stay queued. They are sent when the campaign is resumed.
  3. Buy credits in the console. Check with GET /balance.
  4. Call POST /campaigns/{id}/resume. It succeeds only when your credits cover all the remaining queued messages; otherwise you get 402 insufficient_credits and details.shortfall.

The campaign does not resume by itself after you buy credits. You, or someone in the console, must resume it.

If your balance cannot be checked while a campaign is sending (a temporary problem on our side), the campaign also pauses, with pause_reason balance_check_failed. The messages not sent yet stay queued and nothing was charged for them. Call POST /campaigns/{id}/resume to continue; resuming checks the balance again.

If POST /campaigns times out or the connection drops, you do not know whether the campaign was created. Send the same request with the same Idempotency-Key:

  • If the campaign was created, you get 201 with that campaign and the header X-Idempotent-Replay: true. Nothing new is created or sent. This works for as long as the campaign exists, not only for 24 hours. Within 24 hours you get the first answer again; after that, the campaign as it is now.
  • If it was not created, it is created now.
  • If you send the same key with a different body, you get 409 idempotency_conflict. Send exactly the same body on every retry.
  • The key is remembered for your whole account: a retry with another of your API keys and the same key also returns the same campaign.

Every request to POST /campaigns counts toward its limit of 5 per minute, including retries.