Skip to content

Campaigns

One approved template, a list of people, and a rate. A campaign is built and then started — two calls, deliberately. Nothing goes out until you start it, so a list that is still being uploaded is a draft rather than a half-sent broadcast.

Reading needs the CAMPAIGNS_READ scope. Everything else needs CAMPAIGNS_WRITE, which is granted separately from WHATSAPP_SEND: one call here reaches thousands of people from a number whose sending reputation is yours.

POST /campaigns

Terminal window
curl -X POST "https://vaarto.com/api/v1/campaigns" \
-H "Authorization: Bearer $VAARTO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Diwali offer",
"from": "+919812345678",
"template": {
"name": "diwali_offer",
"language": "en_US"
},
"variables": {
"name": {
"source": "contact",
"field": "name",
"fallback": "there"
},
"code": {
"source": "literal",
"value": "DIWALI25"
}
},
"audience": {
"numbers": [
"+919876543210",
"+919812000111"
]
},
"ratePerMinute": 120
}'
Field Notes
name yours, for finding it again
from one of your numbers, E.164. It must have finished registering with Meta
template the name and language of a template approved on that number’s account
variables one entry per placeholder the template declares
audience who it goes to. May be left out here and added afterwards
ratePerMinute 1–600, default 60. Keep it under what your number’s Meta tier allows
scheduledAt a time in the future. With an audience, the campaign starts itself then

The reply is the campaign, with status: "DRAFT" — or "SCHEDULED" when you gave both a scheduledAt and an audience.

{
"data": {
"id": "cmu…",
"name": "Diwali offer",
"status": "DRAFT",
"templateName": "diwali_offer",
"templateLanguage": "en_US",
"ratePerMinute": 120,
"scheduledAt": null,
"startedAt": null,
"completedAt": null,
"totalCount": 2,
"sentCount": 0,
"failedCount": 0,
"skippedCount": 0,
"createdAt": "2026-10-12T09:14:22.110Z",
"number": { "id": "cnu…", "displayNumber": "+919812345678" }
}
}

There is no idempotency key on this call, so a POST /campaigns that timed out may or may not have built the campaign. Check GET /campaigns before retrying, or create the draft without scheduledAt — a duplicate draft sends nothing until somebody starts it.

variables says where each placeholder’s value comes from, for each person on the list.

{ "source": "literal", "value": "DIWALI25" }
{ "source": "contact", "field": "name", "fallback": "there" }

literal is the same text for everybody. contact reads a field off your address book — name, profileName, company, email or phone — and uses fallback when that field is blank, because Meta rejects an empty parameter outright. A name that arrived with an uploaded recipient answers a {{name}} too, so an upload does not have to become a contact first.

Numbered templates work the same way, keyed by their numbers: { "1": { "source": … } }.

audience is additive, and anybody who lands in it twice is written once.

Field Means
numbers up to 5000 numbers. +919876543210, or 9876543210 read against your country
people the same, each carrying a name: [{ "phone": "+919876543210", "name": "Ravi" }]
groupIds contact groups, by id
allContacts everyone in your address book

The list is resolved and written when you add it, not at send time. So what will go out can be inspected before any of it does, and a contact added to a group an hour later does not silently join a send that was already approved.

A number with no country code is completed rather than refused. Your workspace names the country its bare numbers belong to, under Settings → General, so a ten-digit mobile out of a spreadsheet becomes +91… here. A number written with + is taken as it stands; one that is neither answers 400 and names the entry.

Until a campaign starts, all of it can still change:

Method Path Does
PATCH /campaigns/:id name, from, template, variables, rate, schedule. "scheduledAt": null turns a scheduled campaign back into a draft
POST /campaigns/:id/recipients adds an audience. Anybody already on the list is skipped, not duplicated
DELETE /campaigns/:id/recipients takes people off by recipient id: { "ids": ["cru…"] }
Terminal window
# A long list, 5000 numbers at a time.
curl -X POST "https://vaarto.com/api/v1/campaigns/CAMPAIGN_ID/recipients" \
-H "Authorization: Bearer $VAARTO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"audience": {
"numbers": [
"+919876543210",
"+919812000111"
]
}
}'

Emptying a draft is allowed — that is how one uploaded list is swapped for another.

Method Path Does
POST /campaigns/:id/start starts sending now, and resumes a paused campaign
POST /campaigns/:id/pause stops a running one where it is. Nothing in flight is lost
POST /campaigns/:id/cancel stops it for good. What has gone out has gone out; the rest is marked SKIPPED
DELETE /campaigns/:id deletes one that is not running, and its report with it

start is the gate — the first moment everything has to be true at once:

Failure HTTP Code
nobody on the list 400 BAD_REQUEST
a variable the template declares has no mapping 400 MISSING_VARIABLES
the template is no longer approved by Meta 409 TEMPLATE_NOT_APPROVED
already running, completed or cancelled 400 BAD_REQUEST

A campaign runs DRAFT or SCHEDULED → RUNNING → COMPLETED, with PAUSED and CANCELLED as the two ways out. A cancelled campaign cannot be restarted.

GET /campaigns lists them, newest first, and takes status, page and perPage. GET /campaigns/:id adds the variable mapping. The four counters are the progress: totalCount, sentCount, failedCount and skippedCount.

GET /campaigns/:id/recipients is the report — one row per person, perPage up to 1000, filtered with status=PENDING|SENDING|SENT|FAILED|SKIPPED:

{
"data": [
{
"id": "cru…",
"waId": "919876543210",
"name": "Ravi",
"status": "SENT",
"error": null,
"messageId": "cmu…",
"sentAt": "2026-10-12T09:20:04.000Z",
"variables": { "name": "Ravi", "code": "DIWALI25" }
}
],
"meta": { "page": 1, "perPage": 100, "total": 2 }
}

messageId is the message in your log, so delivery is followed exactly as for a single send — GET /messages/whatsapp/:id, or the message.status webhook. A campaign send is an ordinary message in every other respect, billing included. Campaigns have no webhook events of their own: watch the counters, or the message.status events of the sends.

Anybody who has asked not to be messaged is skipped, not sent to, with the reason on their row. A send that fails transiently is retried up to three times before it is called a failure, and every attempt carries an idempotency key derived from the recipient — so a worker that dies mid-campaign cannot double-send when it comes back.

const campaign = await vaarto.campaigns.create({
name: "Diwali offer",
from: "+919812345678",
template: { name: "diwali_offer", language: "en_US" },
variables: {
name: { source: "contact", field: "name", fallback: "there" },
code: { source: "literal", value: "DIWALI25" },
},
audience: { allContacts: true },
ratePerMinute: 120,
});
await vaarto.campaigns.start(campaign.id);
for await (const row of await vaarto.campaigns.recipients(campaign.id, { status: "FAILED" })) {
console.log(row.waId, row.error);
}