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.
Before you start
Section titled “Before you start”You need:
- An API key with the
sendscope (orfull). Reading campaigns needs onlyread. - 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_idof 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.
Who receives the campaign
Section titled “Who receives the campaign”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).
Step 1: Create the campaign
Section titled “Step 1: Create the campaign”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.
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" }'import { randomUUID } from "node:crypto";
// Create the key once, store it with your own record, and reuse it on every retry.const idempotencyKey = randomUUID();
const res = await fetch("https://api.sanadwa.com/v1/campaigns", { method: "POST", headers: { Authorization: `Bearer ${process.env.SANADWA_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": idempotencyKey, }, body: JSON.stringify({ 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", }),});const body = await res.json();if (!body.success) throw new Error(`${res.status} ${body.code}: ${body.message}`);console.log("Campaign id:", body.data.id, "status:", body.data.status);import osimport uuidimport requests
# Create the key once, store it with your own record, and reuse it on every retry.idempotency_key = str(uuid.uuid4())
res = requests.post( "https://api.sanadwa.com/v1/campaigns", headers={ "Authorization": f"Bearer {os.environ['SANADWA_API_KEY']}", "Idempotency-Key": idempotency_key, }, json={ "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", }, timeout=60,)body = res.json()if not body["success"]: raise RuntimeError(f"{res.status_code} {body['code']}: {body['message']}")print("Campaign id:", body["data"]["id"], "status:", body["data"]["status"])<?php// Create the key once, store it with your own record, and reuse it on every retry.$idempotencyKey = bin2hex(random_bytes(16));
$ch = curl_init("https://api.sanadwa.com/v1/campaigns");curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("SANADWA_API_KEY"), "Content-Type: application/json", "Idempotency-Key: " . $idempotencyKey, ], CURLOPT_POSTFIELDS => json_encode([ "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", ]), CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 60,]);$body = json_decode(curl_exec($ch), true);if (!$body["success"]) { throw new RuntimeException($body["code"] . ": " . $body["message"]);}echo "Campaign id: ", $body["data"]["id"], " status: ", $body["data"]["status"], PHP_EOL;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.
Credits are checked first
Section titled “Credits are checked first”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.
Errors when creating a campaign
Section titled “Errors when creating a campaign”| 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. |
Step 2: Follow the progress
Section titled “Step 2: Follow the progress”Read the campaign with GET /campaigns/{id}:
curl https://api.sanadwa.com/v1/campaigns/312 \ -H "Authorization: Bearer $SANADWA_API_KEY"const res = await fetch("https://api.sanadwa.com/v1/campaigns/312", { headers: { Authorization: `Bearer ${process.env.SANADWA_API_KEY}` },});const { data } = await res.json();console.log(data.status, data.pause_reason, data.counts);res = requests.get( "https://api.sanadwa.com/v1/campaigns/312", headers={"Authorization": f"Bearer {os.environ['SANADWA_API_KEY']}"}, timeout=30,)data = res.json()["data"]print(data["status"], data["pause_reason"], data["counts"])<?php$ch = curl_init("https://api.sanadwa.com/v1/campaigns/312");curl_setopt_array($ch, [ CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("SANADWA_API_KEY")], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30,]);$data = json_decode(curl_exec($ch), true)["data"];echo $data["status"], " ", json_encode($data["counts"]), PHP_EOL;What each status means
Section titled “What each status means”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.
Step 3: Pause and resume
Section titled “Step 3: Pause and resume”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).
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"async function campaignAction(id, action) { const res = await fetch(`https://api.sanadwa.com/v1/campaigns/${id}/${action}`, { method: "POST", headers: { Authorization: `Bearer ${process.env.SANADWA_API_KEY}` }, }); const body = await res.json(); if (!body.success) throw new Error(`${res.status} ${body.code}: ${body.message}`); return body.data; // the campaign after the change}
await campaignAction("312", "pause");await campaignAction("312", "resume");def campaign_action(campaign_id, action): res = requests.post( f"https://api.sanadwa.com/v1/campaigns/{campaign_id}/{action}", headers={"Authorization": f"Bearer {os.environ['SANADWA_API_KEY']}"}, timeout=60, ) body = res.json() if not body["success"]: raise RuntimeError(f"{res.status_code} {body['code']}: {body['message']}") return body["data"] # the campaign after the change
campaign_action("312", "pause")campaign_action("312", "resume")<?phpfunction campaignAction(string $id, string $action): array { $ch = curl_init("https://api.sanadwa.com/v1/campaigns/$id/$action"); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("SANADWA_API_KEY")], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 60, ]); $body = json_decode(curl_exec($ch), true); if (!$body["success"]) throw new RuntimeException($body["code"] . ": " . $body["message"]); return $body["data"]; // the campaign after the change}
campaignAction("312", "pause");campaignAction("312", "resume");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.
When credits run out
Section titled “When credits run out”If your balance runs out while a campaign is sending:
- SanadWA stops sending. The campaign’s
statusbecomespausedwithpause_reasoninsufficient_credits. - The messages not sent yet stay
queued. They are sent when the campaign is resumed. - Buy credits in the console. Check with
GET /balance. - Call
POST /campaigns/{id}/resume. It succeeds only when your credits cover all the remainingqueuedmessages; otherwise you get402 insufficient_creditsanddetails.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.
Retrying a create safely
Section titled “Retrying a create safely”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
201with that campaign and the headerX-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.