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 agentId is set, the agent must exist and be published - else 422 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.fromNumber is set, it must be an active number you own - else 400 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 (so 98123 45678 works), 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 - else 400 missing_required_variables (meta.missing lists them), and nothing is stored.
  • Refused with 409 while 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 suspended
  • 404 campaign not found
  • 409 campaign must be published before it can run (status isn’t active)

Runs

  • GET /v1/campaigns/:id/runs  ·  scope campaigns:read → { "data": run[] }. Unknown campaign → 404.
  • GET /v1/campaign-runs/:runId  ·  scope campaigns: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 progress states (reached, inProgress, unreached, held, queued, invalid) never overlap, so they add up to total. retryable counts the unreached contacts another attempt could still reach. blocked counts contacts that were never dialled because a compliance rule (DND, calling hours, …) blocked every attempt; they are not counted in called.
  • heldByReason says why held contacts 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 →