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.
Build one
Section titled “Build one”POST /campaigns
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 }'const res = await fetch("https://vaarto.com/api/v1/campaigns", { method: "POST", headers: { Authorization: `Bearer ${process.env.VAARTO_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ 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, }),});
const { data } = await res.json();import os, requests
res = requests.post( "https://vaarto.com/api/v1/campaigns", headers={"Authorization": f"Bearer {os.environ['VAARTO_API_KEY']}"}, json={ "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, }, timeout=10,)
data = res.json()["data"]$data = Http::withToken(env('VAARTO_API_KEY')) ->post('https://vaarto.com/api/v1/campaigns', [ '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, ]) ->json('data');| 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, per recipient
Section titled “Variables, per recipient”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": … } }.
Who it goes to
Section titled “Who it goes to”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…"] } |
# 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" ] } }'// A long list, 5000 numbers at a time.const res = await fetch("https://vaarto.com/api/v1/campaigns/CAMPAIGN_ID/recipients", { method: "POST", headers: { Authorization: `Bearer ${process.env.VAARTO_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ audience: { numbers: ["+919876543210", "+919812000111"], }, }),});
const { data } = await res.json();import os, requests
# A long list, 5000 numbers at a time.res = requests.post( "https://vaarto.com/api/v1/campaigns/CAMPAIGN_ID/recipients", headers={"Authorization": f"Bearer {os.environ['VAARTO_API_KEY']}"}, json={ "audience": { "numbers": ["+919876543210", "+919812000111"], }, }, timeout=10,)
data = res.json()["data"]// A long list, 5000 numbers at a time.$data = Http::withToken(env('VAARTO_API_KEY')) ->post('https://vaarto.com/api/v1/campaigns/CAMPAIGN_ID/recipients', [ 'audience' => [ 'numbers' => ['+919876543210', '+919812000111'], ], ]) ->json('data');Emptying a draft is allowed — that is how one uploaded list is swapped for another.
Start, pause, cancel
Section titled “Start, pause, cancel”| 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.
Watch it
Section titled “Watch it”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.
In the client library
Section titled “In the client library”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);}