Running campaigns
A campaign calls many recipients with one agent, on a schedule and at a controlled pace. This guide walks through creating a campaign, adding recipients, starting, pausing, resuming and stopping it, and reading per-recipient results.
Use a campaign whenever you call more than a handful of people for the same purpose: it paces calls for you (concurrency_limit at a time), screens every recipient against your compliance rules, and gives you one object to follow instead of hundreds of calls.
Before you begin
| You need | For |
|---|---|
campaigns:write, campaigns:read | Creating, starting and reading campaigns |
contacts:write | Recipients are contacts: you upsert them first |
| A ready agent | In test mode, the Sample agent agent_1ZltiWzxrXmMprGD6q63 |
webhooks:write (optional) | To receive campaign.* events |
The campaign lifecycle
Text
| Status | Meaning |
|---|---|
draft | Created, never started. Nothing has been dialled. |
running | A run is dialling, up to concurrency_limit calls at a time. |
paused | No new calls start. Calls already connected finish normally (and are charged). |
stopped | Ended by a stop request. No further calls start. |
completed | The run worked through every recipient. |
failed | The run ended in failure before finishing. |
unknown | The state could not be determined. Not a success. Open enum. |
Every campaign carries capabilities, which says what is allowed right now. Check it instead of hard-coding the table:
| Action | Allowed from | capabilities field |
|---|---|---|
| Start | draft, paused, stopped, completed, failed | start |
| Pause | running | pause |
| Resume | paused | resume |
| Stop | draft, running, paused | stop |
| Edit (PATCH) | every status except running | update |
| Add recipients | draft only | add_recipients |
A campaign can also be paused automatically, for example when its budget cap is reached, the workspace balance runs out, the telephony connection drops, too many consecutive calls fail, or a start was refused. The campaign.paused event's data.reason says why (budget_cap_reached, insufficient_balance, spend_limit_reached, telephony_disconnected, failure_streak, start_refused, account_inactive, requested, unknown). Fix the cause, then resume.
Create and start a campaign
Three steps: upsert the recipients as contacts, create the campaign with their ids, start it.
1. Upsert each recipient as a contact. Send one request per person (this one adds the test number that completes a call):
Keep the id of each contact (ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1 here). Add "consent_granted": true only when the person really agreed to be called.
2. Create the campaign with the contact ids. It starts as a draft; nothing is dialled:
Response 201:
JSON
3. Start it, using the id from that response:
Response 200:
JSON
If a job stops halfway, check what already exists before sending a step again: every request is executed, so resending a create makes a second campaign. The complete example below looks for its campaign by name first, creates it only if it is missing, and starts it only while it is still a draft. An optional Idempotency-Key per step, saved with your batch, makes a re-run replay the steps that already happened instead (see Idempotency).
Complete runnable example
Create a test-mode campaign with three recipients and start it.
The same steps as a complete script, with error handling, tested against test mode on every docs build. Each script reads your test key from an environment variable named in its first lines.
- cURL script (bash)
create-and-start.sh - JavaScript (Node.js 20+)
create-and-start.mjs - Python 3.9+ (requests)
create-and-start.py
The calls in order:
- POST
/contacts/upsertfor each person: recipients are always existing contacts (see Managing contacts). - POST
/campaignswithname,agent_id, optionalconcurrency_limit(default 1; lowered at start to your plan'slimits.campaign_max_concurrency), optionalbudget_capand up to 1000contact_ids. The answer is201with the campaign indraft. Nothing is dialled yet. - Optionally POST
/campaigns/{campaign_id}/recipientsto add more contacts, up to 1000 per request and 10,000 per campaign, only while the campaign is a draft. Contacts already in the campaign are skipped, and the response says how many wereadded. - POST
/campaigns/{campaign_id}/start.
What the start does
Before anything is dialled, the start checks the agent, the account, balance and spend limits, the campaign budget cap, calling hours and the telephony connection, then screens every recipient against the do-not-call list, consent and the per-number daily limit. Blocked recipients are left out of the run; if that leaves nobody, the start is refused with campaign_recipients_suppressed. After the start, recipient_count is the number of recipients this run calls.
200: The run started; the campaign isrunning. Follow it.202: The start was sent but its confirmation has not arrived; the campaign showsrunningand is reconciled automatically. Read it again in a minute. Do not start it again.409campaign_outcome_unknown: the start was sent and not confirmed; dialling may have begun. WaitRetry-After, then read the campaign:runningmeans it started. Only if it did not, start it again (if you sent an idempotency key, use a new one: the same key returns this answer again).409campaign_dnc_override_enabled: do-not-call screening was switched off for this campaign in the console (dnc_screening: "disabled_in_console"). The API never starts or resumes such a campaign. An admin switches the setting back on, or the campaign is started from the console.
In live mode a start counts every recipient against the workspace's daily limit of calls placed through the API, before dialling, because the run may call each of them. A start that would go past the limit is refused whole with daily_call_limit_reached (details.requested and details.remaining say by how much), so a campaign larger than the daily limit cannot be started through the API. A resume never counts. Test mode has no daily limit.
When the do-not-call list is checked. Recipients are screened when a run starts and again when it resumes, not before each call. A number added to the do-not-call list while the campaign is running is still called by that run if it has not been reached yet. To apply the new entry, pause and resume the campaign (the resume is then refused while that number is a remaining recipient, see below) or stop it. dnc_screening on the campaign is enforced for every campaign the API can start.
Follow results
Subscribe an endpoint to the campaign.* events (see Receiving webhooks) and read the campaign and its recipients when they arrive:
| Event | When |
|---|---|
campaign.started | A run started dialling (also when a 202 start is confirmed) |
campaign.paused | Paused by a request or automatically; data.reason says why |
campaign.resumed | A paused run resumed |
campaign.stopped | Stopped; data.reason is requested |
campaign.completed | The run worked through every recipient. In live mode up to about 15 minutes after the last call ends. |
campaign.failed | The run ended in failure. In live mode up to about 15 minutes after it ends. |
Each call a campaign places is an ordinary call with source: "campaign" and campaign_id set, so call.ended and call.settled arrive for each one, and GET/calls with campaign_id lists them.
Read the campaign:
And its recipients, 100 at a time (follow next_cursor as described in Pagination):
Complete runnable example
Show a campaign's status and every recipient's result.
The same steps as a complete script, with error handling, tested against test mode on every docs build. Each script reads your test key from an environment variable named in its first lines.
- cURL script (bash)
follow-results.sh - JavaScript (Node.js 20+)
follow-results.mjs - Python 3.9+ (requests)
follow-results.py
A running campaign (200, with an ETag header):
JSON
progress counts the calls of the current or latest run (calls, connected, no_answer, failed, in_progress) and can lag by up to a minute. spend is what the run has cost so far (always zero in test mode).
GET/campaigns/{campaign_id}/recipients lists recipients in a stable order that does not change between pages (not by time), with what happened to each in the current or latest run:
status | Meaning |
|---|---|
pending | Not called yet in this run |
in_progress | Being called |
completed | Called; see outcome (the call outcome values) and last_call_id |
failed | Every attempt failed |
suppressed | Left out by screening; suppression_reason is dnc_listed, consent_required, contact_daily_limit_reached or unknown |
cancelled | The run stopped before reaching them |
unknown | Not known. Open enum. |
A page of recipients from a test-mode campaign (200):
JSON
Per-recipient results can be up to a minute behind the calls. One honest limit today: in live mode a recipient left out by screening at start is not yet recorded per recipient. It shows pending while the run is open and unknown once the run has ended (test mode records it as suppressed). The difference between the campaign's recipients and recipient_count after the start tells you how many were left out.
Start, pause, resume and stop
Read the campaign first and check capabilities. Then send the action. Pause:
Resume:
Stop:
Each answers 200 with the campaign in its new status. After a timeout or a retry: "yes" error, read the campaign before sending the action again; after a retry: "after" error, wait Retry-After and send it again. On campaign_outcome_unknown, do not resend: wait Retry-After and read the campaign.
Complete runnable example
Pause, resume or stop a campaign, checking first that the action is allowed.
The same steps as a complete script, with error handling, tested against test mode on every docs build. Each script reads your test key from an environment variable named in its first lines.
- cURL script (bash)
campaign-action.sh - JavaScript (Node.js 20+)
campaign-action.mjs - Python 3.9+ (requests)
campaign-action.py
- Pause (POST
/campaigns/{campaign_id}/pause, fromrunning): no new calls start. Calls already connected finish normally and are charged. The campaign is reportedpausedonly once the pause is confirmed; if it is not, you getcampaign_pause_not_confirmedand the campaign may still be dialling. Calls placed meanwhile are charged as usual. Read the campaign; if it is still running, send the pause again or stop it. - Resume (POST
/campaigns/{campaign_id}/resume, frompaused): continues the same run with the settings it began with. Before anything is sent it repeats the account, balance, budget, calling-hours and telephony checks and screens every recipient the run could still call again. A resume cannot drop recipients from a run, so if any remaining recipient is now blocked (for example added to the do-not-call list after the start), nothing is resumed, the campaign stayspaused, and you getcampaign_recipients_blockedwithdetails.blocked_total,details.by_reasonand up to 100details.contact_ids. Do not stop and start it again: a new start calls every recipient of the previous run again, including people already reached, and charges for those calls. To continue without the blocked people: list the recipients withstatus=pending(not reached yet), create a new campaign with those contacts minus the ones indetails.contact_ids, start it, then stop this campaign (stopping does not hang up calls in progress). Never work around the refusal by removing do-not-call entries or recording consent nobody gave. - Stop (POST
/campaigns/{campaign_id}/stop, fromdraft,runningorpaused): no further calls start. Calls already connected are not hung up; they finish normally. Recipients the run had not reached becomecancelled. - Start again from
paused,stopped,completedorfailedbegins a new run that calls the previous run's recipients again, including people already reached (and charges for those calls): frompausedit abandons the paused run (use resume to continue it instead).run_numberincreases. Use it when you really want to call everyone again; to reach only the people not called yet, create a new campaign with them.
Every action is a POST. After a timeout read the campaign before resending (its status shows whether the action took effect); after a retry: "after" error wait and resend; stop on retry: "no"; and on campaign_outcome_unknown wait and read the campaign instead of resending.
Edit safely
PATCH/campaigns/{campaign_id} changes name, agent_id, concurrency_limit or budget_cap (null removes the cap). Edits are allowed in every status except running, and take effect at the next start: a resume continues with the settings its run began with.
Campaigns use optimistic concurrency. Every read returns an ETag header; send it back in If-Match. If the campaign changed since you read it (someone edited it in the console, or it was paused automatically), you get version_conflict (412): read it again, decide again, and send the new ETag. A PATCH without If-Match is precondition_required (428). See Concurrency.
Read the campaign and keep its ETag header, then send the change with it in If-Match:
Complete runnable example
Rename a campaign and change its concurrency with If-Match.
The same steps as a complete script, with error handling, tested against test mode on every docs build. Each script reads your test key from an environment variable named in its first lines.
- cURL script (bash)
edit-settings.sh - JavaScript (Node.js 20+)
edit-settings.mjs - Python 3.9+ (requests)
edit-settings.py
Budget, pace and limits
budget_cap, as{"amount": <minor units>, "currency": "INR"}, is the spend at which the campaign is paused (reasonbudget_cap_reached). It is not an exact ceiling: the check runs about every 15 minutes, so spend can exceed the cap by the calls placed in that time. Set it with that margin in mind; raise or remove it with PATCH and resume. A start or resume at the cap is refused withcampaign_budget_reached.concurrency_limitis how many calls run at once. A higher value is lowered at start tolimits.campaign_max_concurrencyon GET/workspace.- Starts and resumes use the
dialrate-limit class (30 per minute per workspace); pause, stop and edits usewrite. - Calling hours apply to the whole campaign: a start or resume outside the calling window is refused with
outside_calling_hours(the window and the timezone it is evaluated in are indetails).
What can go wrong
campaign_state_conflict(409): The action is not allowed in the current status (details.status,details.action). Read the campaign and usecapabilities.campaign_operation_in_progress(409): Another start or resume of this campaign is running. WaitRetry-After, then send it again.campaign_outcome_unknown(409): A start or resume was sent but not confirmed. Wait, then read the campaign, and send the action again only if it did not start (with a new idempotency key if you use keys). Ifdetails.needs_supportis true, contact support.campaign_dnc_override_enabled(409): Do-not-call screening is switched off for this campaign in the console. An admin switches it back on, or starts it from the console.campaign_has_no_recipients(409): Starting a campaign with no recipients. Add recipients while it is a draft.campaign_recipients_suppressed(422): Every recipient is blocked by screening. Add callable recipients; review consent records.campaign_recipients_blocked(409): Resume found newly blocked recipients. Create a new campaign with the pending recipients minus the blocked ones, then stop this one.campaign_not_resumable(409): The paused run can no longer continue. A new start calls every previous recipient again; to reach only the people not called yet, create a new campaign with the pending recipients.campaign_recipients_unavailable(503): Recipients could not be read for screening; nothing was dialled. WaitRetry-Afterand send it again. If it keeps failing on a very large campaign, create a new campaign with the pending recipients instead.campaign_too_large(422): Too many recipients for one run. Split the audience into several campaigns.recipient_contacts_not_found(422): Somecontact_idsare not contacts of this workspace and mode. Upsert them first;detailslists them.telephony_disconnected(422): The telephony connection is not working; the campaign was paused. Reconnect it in the console, then start or resume.agent_not_ready,insufficient_balance,spend_limit_reached,account_inactive: Same checks as a single call. See Making an outbound call.
Check it works in test mode
- Run the create-and-start example. The campaign becomes
runningwithrecipient_count: 3. - Follow it: within about a minute (with
concurrency_limit: 2) the recipients arecompletedwith outcomescompleted,no_answerandcompleted, and the campaign becomescompleted. - Start another one with
concurrency_limit: 1and pause it while at least one recipient is stillpending. Add that recipient's number to the do-not-call list and resume: you get409 campaign_recipients_blockedlisting it. Create a new campaign with the remaining pending recipients minus that one, start it, and stop the first.