Campaigns
Create outbound campaigns and run them at scale.
A campaign dials a list of contacts with a published agent, on a schedule and within your compliance window. The typical flow: create → set contacts → publish → run, then poll the run for progress.
Campaign object:
{
"id": "cmp_…",
"name": "August outreach",
"channel": "voice",
"workflowType": "collections",
"dltTemplateRef": null,
"agentId": "agt_…",
"status": "draft",
"settings": { "fromNumber": "+91…", "timezone": "Asia/Kolkata", "startAt": null, "endAt": null,
"dailyFrom": "10:00", "dailyTo": "19:00", "retryCount": 2, "cooldownMinutes": 30,
"emailNotifications": [] },
"callingList": null
}
Run object:
{
"id": "run_…", "campaignId": "cmp_…", "status": "running", "source": "schedule",
"attempted": 120, "placed": 96, "rejected": 24,
"skippedReason": null, "error": null,
"createdAt": "2026-08-20T10:00:00Z", "finishedAt": null
}
List campaigns
GET /v1/campaigns · scope campaigns:read → { "data": campaign[] } (all in the workspace).
Get a campaign
GET /v1/campaigns/:id · scope campaigns:read → { "data": campaign }. Unknown → 404 campaign not found.
Create a campaign
POST /v1/campaigns · scope campaigns:write
Body
| Field | Type | Default / constraints |
|---|---|---|
name |
string | max 120, default "" |
channel |
enum | voice, whatsapp - default voice. voice and (when WhatsApp campaigns are enabled for the workspace) whatsapp are available: see WhatsApp campaigns. Any other channel is refused with 400 channel_not_supported. |
whatsapp |
object | Required for channel: "whatsapp" (see WhatsApp campaigns); refused on any other channel (422 whatsapp_config_unexpected). |
workflowType |
enum | collections, onboarding - default collections |
agentId |
string | null | default null |
dltTemplateRef |
string | null | optional free-text reference; default null |
settings |
object | see below |
settings: fromNumber (string|null), timezone (valid IANA tz|null), startAt/endAt/dailyFrom/dailyTo (string|null), retryCount (int 0–10, default 0), cooldownMinutes (int ≥0, default 0), emailNotifications (string[] of emails, default []).
Validation
- If
agentIdis set, the agent must exist and be published - else422 agent must be published before it can run a campaign. A voice campaign also needs a call agent with direction outbound (422 agent_not_call/agent_not_outbound). - If
settings.fromNumberis set, it must be an active number you own - else400 from number is not an active number you own.
Response 201: { "data": campaign } (status: "draft", callingList: null).
Update a campaign
PUT /v1/campaigns/:id · scope campaigns:write
PATCH semantics - send only what changes: name, dltTemplateRef, settings (each sub-field optional, merged over current). A campaign’s agent is fixed once it’s created - sending a different agentId is 409. The owned-number check runs only when you send settings.fromNumber. Unknown → 404.
Set the calling list
POST /v1/campaigns/:id/contacts · scope campaigns:write · rate limited
Replaces the entire calling list.
{
"contacts": [
{ "phone": "+919812345678", "name": "Nalin", "variables": { "amount": "4999" } },
{ "phone": "+919812345679", "name": "Asha" }
]
}
contacts- 1 to 50,000 rows. Each row:phone(8–20, required),name(optional),variables(optional object of strings).- Each phone is normalized to
+91…first (so98123 45678works), then validated. Duplicates - including the same number written differently - keep the first occurrence. Invalid and duplicate rows are stored flagged and skipped at dial time. - If the campaign’s agent has a flow that requires variables, every one must appear in the rows’
variables- else400 missing_required_variables(meta.missinglists them), and nothing is stored. - Refused with
409while the campaign is dialling - pause it first.
Response 200:
{ "data": { "total": 2, "valid": 2, "invalid": 0 } }
Publish / pause
POST /v1/campaigns/:id/status · scope campaigns:write
{ "status": "active" } // "draft" | "active" | "paused"
Publishing (active) requires the attached agent to be published, and - for a voice campaign - an outbound call agent (422 otherwise). Response 200: { "data": campaign }.
Start a run
POST /v1/campaigns/:id/run · scope campaigns:write · rate limited · no body
Kicks off background dialing.
Response 202:
{ "data": { "runId": "run_…", "status": "queued" } }
403 workspace suspended404 campaign not found409 campaign must be published before it can run(status isn’tactive)
Runs
GET /v1/campaigns/:id/runs· scopecampaigns:read→{ "data": run[] }. Unknown campaign →404.GET /v1/campaign-runs/:runId· scopecampaigns:read→{ "data": run }. Unknown →404 run not found.
Poll a run’s status, attempted/placed/rejected counts, and skippedReason (e.g. outside_schedule, kyb_unverified, insufficient_balance, concurrency_limit, run_error) to track progress.
source says where the run came from: manual (the console’s Run button), api (POST /v1/campaigns/:id/run) or schedule (the automatic dialer, which works through an active campaign’s list during its schedule window). A schedule run covers one dialing session - consecutive dialing ticks are folded into the same run - and the dialer records a run that dialed nothing only when its skippedReason changes, not every minute.
Progress summary
GET /v1/campaigns/:id/summary · scope campaigns:read
How far the campaign has got, and how it’s doing. Voice campaigns only: a WhatsApp campaign answers 409 whatsapp_campaign,
because its funnel is whatsapp.stats on GET /v1/campaigns/:id.
{
"data": {
"progress": { "total": 1200, "reached": 610, "inProgress": 4, "unreached": 180, "held": 26, "queued": 372, "invalid": 8, "retryable": 150, "blocked": 12, … },
"heldByReason": { "calling_window": 20, "frequency": 6 },
"funnel": { "contacted": 790, "advancing": 140, "won": 52, "lost": 90 },
"winMetric": { "label": "Meeting booked", "count": 52 },
"connectRate": 0.77,
"avgCallSeconds": 94,
"spentMinor": 431200,
"costPerOutcomeMinor": 8292,
"lifecycle": { "createdAt": "…", "startedAt": "…", "lastCallAt": "…", "completedAt": null }
}
}
- The six
progressstates (reached,inProgress,unreached,held,queued,invalid) never overlap, so they add up tototal.retryablecounts the unreached contacts another attempt could still reach.blockedcounts contacts that were never dialled because a compliance rule (DND, calling hours, …) blocked every attempt; they are not counted incalled. heldByReasonsays whyheldcontacts are waiting (for example outside calling hours, or the frequency cap).- Money is in minor units (paise). For the wallet balance, use
GET /v1/wallet.
Calling-list progress
GET /v1/campaigns/:id/contacts · scope campaigns:read
The calling list, with where each contact is.
| Param | Type | Notes |
|---|---|---|
state |
string | all (default), reached, in_progress, unreached, held, queued, invalid, or dnc (on do-not-call). |
q |
string | Match a name or phone number. |
limit |
int | 1–200, default 50. |
offset |
int | Rows to skip, default 0. |
{
"data": [
{
"id": "cc_…", "contactId": "bor_…", "phone": "+9198…", "name": "Asha", "variables": { "due": "12 Oct" },
"state": "held", "dialStatus": "pending", "attempts": 0, "lastAttemptAt": null, "heldReason": "calling_window",
"valid": true, "invalidReason": null, "doNotCall": false, "boardColumn": null
}
],
"counts": { "all": 1200, "reached": 610, "in_progress": 4, "unreached": 180, "held": 26, "queued": 372, "invalid": 8, "dnc": 11 },
"total": 1200,
"nextOffset": 50
}
total is the count for the chosen state; pass nextOffset as offset to get the next page (null on the last one). For the calls themselves, use GET /v1/calls?campaignId=….
WhatsApp campaigns
A WhatsApp campaign sends one approved template to everyone on its list - no calls, no runs. It needs the workspace’s WhatsApp campaigns feature (403 feature_disabled otherwise) and a connected WhatsApp number.
Scopes. A WhatsApp campaign needs the key’s WhatsApp scope as well as the campaign scope: whatsapp:write to create, update, delete, set the list of, or publish / pause one, and whatsapp:read to list its contacts. Without it the call answers 403 api_scope_missing (meta.scope names the missing scope). Reading a WhatsApp campaign without whatsapp:read (with campaigns:read alone, or with whatsapp:write but not whatsapp:read) returns it without its whatsapp block. A key made for voice campaigns therefore cannot send WhatsApp messages.
Create it with channel: "whatsapp" and a whatsapp block:
{
"name": "Diwali renewals",
"channel": "whatsapp",
"whatsapp": {
"templateId": "watpl_…",
"paramMapping": {
"body.1": { "source": "column", "value": "name" },
"body.2": { "source": "column", "value": "plan" },
"button.0": { "source": "static", "value": "RENEW10" }
},
"ratePerMinute": 60,
"optInPolicy": "require"
},
"settings": { "timezone": "Asia/Kolkata", "dailyFrom": "09:00", "dailyTo": "20:00", "startAt": null, "endAt": null }
}
whatsapp field |
Meaning |
|---|---|
templateId |
An approved template (watpl_…, see WhatsApp templates). It can’t change once the campaign is published. |
paramMapping |
Where each template value comes from. Keys: header (a text header variable), header_media (a media header’s https link), body.<n or name>, button.<index> (a link ending or a code to copy). Values: { "source": "column", "value": "<list variable or name>" }, { "source": "contact", "value": "name" | "email" | "phone" | "<attribute>" } or { "source": "static", "value": "<text>" }. A person whose values are missing is skipped (missing_params). |
phoneNumberId |
The number to send from (Meta’s phone number id). Leave it out to send from your default number; it is required (422 phone_number_required) only when several numbers are live and none of them is the default. |
ratePerMinute |
Messages per minute, 1-600 (default 60). Slower sends protect your number’s quality rating. |
optInPolicy |
require: only people with a recorded WhatsApp opt-in (the default for marketing templates); skip_check: everyone who has not opted out (the default for utility / authentication). A marketing campaign can use skip_check only when the workspace turned off its marketing opt-in rule. |
The audience is the calling list: POST /v1/campaigns/{id}/contacts takes any international number for a WhatsApp campaign (a variable whatsapp_opt_in set to yes, true, 1 or y records that person’s opt-in). The list is fixed once the campaign has started sending (409 campaign_launched).
Publish (POST /v1/campaigns/{id}/status with active) runs the pre-flight first and answers 409 preflight_blocked with the report in meta.report when the template is not approved, the number is not connected, the quality rating is RED, or nobody can be messaged. The report counts who will receive it and who will not (invalid numbers, duplicates, opted out, no opt-in, missing values), the messaging-limit tier and the days it needs, the sending time, Meta’s estimated charges, and warnings. paused pauses it; active on a paused campaign resumes it (behind the same checks). Pass "acknowledgeQuality": true to send despite a RED quality rating, and "acknowledgeRecategorisation": true to resume a campaign whose template became marketing. Start a run is refused (409 whatsapp_campaign): a published WhatsApp campaign sends on its own.
Sending. Messages go out at ratePerMinute, inside the daily window (09:00-21:00 in the campaign timezone when you leave dailyFrom/dailyTo out; send both as null to allow any hour) and dates, within your WhatsApp messaging limit (the people you can message in 24 hours; the rest wait for the limit to free up). Everyone is re-checked when their message goes out: a STOP, your do-not-contact list, an erasure or a missing marketing opt-in skips them. Each person gets the message at most once, even across restarts.
Automatic pauses. The campaign pauses itself, with the reason in whatsapp.pausedReason, when Meta limits the number for spam reports (spam_rate_limited), the template stops being approved (template_unavailable), Meta recategorises a utility template to marketing (template_recategorised - resume with "acknowledgeRecategorisation": true; from then on only people with a recorded opt-in receive it), the number or template quality drops to RED (quality_red), half of the recent sends fail (high_failure_rate; Meta’s frequency-cap holds don’t count), or the account is restricted, disconnected or has a payment problem (account_restricted, token_invalid, not_connected, payment_issue, permission_denied, number_not_registered). Resume it once fixed.
Test keys can create and publish WhatsApp campaigns to try the API: every check and the pre-flight run (the same errors as a live key), the answer carries livemode: false, and nothing is stored, launched or sent. Other campaign writes with a test key answer 403 test_mode_read_only.
Replies go to the WhatsApp agent answering the number, with the person’s list values as flow variables, and the finished conversation is placed on the campaign’s outcome board.
A WhatsApp campaign’s object adds a whatsapp block: templateId, paramMapping, optInPolicy, ratePerMinute, pausedReason, launchedAt, finishedAt and, on GET /v1/campaigns/{id}, stats - the funnel: total, pending, sent, delivered, read, replied, failed (failures at WhatsApp), frequencyCapped (held back by Meta’s per-person frequency caps such as India’s marketing cap, error 131049 - not counted as failures and never pausing the campaign), skipped, deliveryUnknown, skippedByReason, failedByCode, converted (cards in a won column of the campaign’s board) and optedOutAfter. sent counts everyone messaged; delivered, read and replied are cumulative steps of it. It completes when everyone was messaged or skipped (or at its end date) and sends campaign.finished with the final funnel.
Delete a campaign
DELETE /v1/campaigns/:id · scope campaigns:write → { "data": { "id": "cmp_…", "deleted": true } }.
409 pause the campaign before deleting it(active campaigns can’t be deleted)404 campaign not found
Next: Phone numbers →