A campaign is the bulk counterpart to
telephony.originate, which
places exactly one call and returns its call_sid. If you want one
call, use originate. The console’s Campaigns screen exposes both
behind the same drawer.
The campaign object and its fields
startCampaign writes a campaign record to Redis (hash key
campaignData(orgId, campaign_id)) and adds the id to the org’s
campaign set. listCampaigns reads those hashes back. Because the
record is a Redis hash, every field comes back as a string —
including total_numbers, loaded_numbers, calls_per_second, and
max_concurrent_calls. Coerce before you do arithmetic.
listCampaigns sorts descending on created_at, so the newest run is
first. Records with an empty hash are filtered out. There is no
pagination and no filter input — the console filters and searches the
returned array client-side.
Read loaded_numbers in preference to total_numbers when showing
progress-facing counts: total_numbers is what you submitted,
loaded_numbers is what the engine confirmed. The console renders
loaded_numbers ?? total_numbers.
The dial list and its format rules
The dial list travels separately from the template.startCampaign
writes the numbers, newline-joined, to
campaignNumbers(orgId, campaign_id) — the key mod_sip’s
campaign.start reads. The bulk originate RPC carries the template
(trunk, caller ID, dialplan action, pacing); the list hand-off is the
Redis write.
numbers is an array of strings. Each entry must be at least 3
characters, and the array must contain between 1 and 50000 entries. A
list over 50000 is rejected by input validation before anything is
persisted.
CSV dial lists: accepted columns and skipped rows
The CSV is parsed in the browser; the API only ever sees the resulting array of strings. The console’s rules:- Empty lines are skipped.
- If the first row’s cells (lowercased and trimmed) contain one of
to,number,phone,msisdn,e164, that row is treated as a header and that column is used. - With no recognised header, the first column is used and every row is treated as data.
- A cell is kept only if it matches
^\+?\d{7,15}$— an optional leading+, then 7 to 15 digits. Rows that fail are skipped, not dialed.
loaded_numbers can be lower than your file’s
row count for that reason as well.
Pacing: calls per second vs max concurrent
The two pacing knobs are independent and both apply. The engine paces the dial-out per CPS and per max-concurrent.calls_per_secondis the origination rate — how quickly new calls leave the trunk. Accepted range: integer 1–200. Default2.max_concurrent_callsis the ceiling on live calls at any instant. Accepted range: integer 1–2000. Default20.
min/max attributes on the
console’s number inputs are inert — the range check is the gate), and
both are stored on the campaign record so listCampaigns can show the
pacing a run was launched with.
Connecting answered calls to an agent
default_app picks what happens when a call is answered. It takes the
same dialplan actions as a single originate:
The default is
AI_BIDIRECTIONAL_STREAM, which is what the Voice AI
Campaigns screen always sends. The console’s bulk form omits PLAYBACK
from the picker and offers it only on the manual single-call path.
Note the difference from originate: the single-call path hydrates the
agent’s provider keys first and fails the request with a 400 listing
the missing providers. startCampaign does not perform that check.
Verify an agent works with a manual originate before you point a
50000-number list at it.
Campaign statuses and what moves between them
status is a plain string on the campaign hash. Three writes set it:
So
starting is the window between the Redis write and the engine’s
load acknowledgement. A record stuck on starting means the bulk
originate RPC did not come back — the numbers are persisted but the
engine never confirmed the load.
Completion is reported by the engine, and the consoles accept more than
one spelling for it: the Campaigns screen’s Completed filter
matches completed, finished, and done. Treat all three as the
same terminal state. If you write your own list view, normalise those
values rather than testing for one.
aborted is terminal from the API’s side: there is no resume
procedure. startCampaign, listCampaigns, and abortCampaign are
the entire surface. To re-dial the remainder of an aborted list, start
a new campaign with a new campaign_id and the numbers you still
want dialed.
The consoles treat running as the only state offering an abort
control in some screens, and everything except aborted in others.
Either is safe — abortCampaign does not validate the current status.
Aborting a campaign
status: 'aborted' on the campaign hash and returns the engine’s
response.
What abort does and does not do:
- Stops further dialing. The engine stops pulling from the dial list.
- Does not hang up calls already in flight. Abort ends the
campaign’s dial-out, not the calls it already placed. Calls that are
ringing or already answered and bridged to an agent continue on their
own terms — they finish when the far end hangs up, when the dialplan
action completes, or when
max_duration_msexpires. To end a specific in-flight call, hang that call up by itscall_sid. - Cannot be undone. There is no un-abort and no resume.
- Marks the record locally regardless. The Redis write happens after the engine call; if the engine call fails, the mutation surfaces the error and the status is not rewritten.
listCampaigns.
Reading campaign progress
listCampaigns is the progress surface, and it is deliberately thin:
it returns the stored campaign records. That gives you status,
loaded_numbers, total_numbers, and the pacing the run was launched
with. It does not return dialed / answered / failed counters, and
there is no per-campaign outcome procedure in this surface.
Both consoles poll listCampaigns on a 5-second interval and re-render
the status chip and loaded count from the result. If you build your own
view, do the same — there is no subscription or webhook on the campaign
object itself.
For per-call outcomes of a campaign run, go to the call-level data
rather than the campaign record. Each call the campaign places produces
the same per-call telemetry as any other outbound call — call events,
and a Call Detail Record on hangup with status, q850_cause,
duration_seconds, and the audio-quality fields. Filter those by
tenant and time window covering the run to compute answer and
completion counts. See Telephony Metrics for the
definitions of ASR, ACD, connect rate, and abandon rate, and
Telemetry for the CDR schema and the metric
families to aggregate.
Campaign records live in Redis under the org’s campaign set. Nothing in
this surface sets an expiry on them, and there is no delete procedure —
listCampaigns returns every record the org has ever started until it
is removed out-of-band. Plan your list UI for a growing history:
filter by status and search by name or campaign_id, as the consoles
do.Limits
Every limit below comes fromstartCampaign’s input validation. A
request violating any of them is rejected before the campaign record or
the dial list is written.
The 50000 ceiling is a per-request limit on the
numbers array, not a
cap on how many numbers you can dial. Split a larger list across
several campaigns with distinct campaign_id values. Pacing does not
change the ceiling: a 50000-number list at the default pacing is still
one campaign object, and it dials for as long as CPS and
max-concurrent make it take.
API reference
startCampaign
status: 'starting'
→ add the id to the org’s campaign set → write the newline-joined
number list → call the engine’s bulk originate with the template →
overwrite status and loaded_numbers from the ack.
listCampaigns
abortCampaign
orgId is required and the caller
must be authorised for that org. See
Authentication for how your backend
authenticates to the control plane.
Related
- Telemetry — CDR schema and metric families for per-call outcomes
- Telephony Metrics — CPS, concurrent channels, ASR, abandon rate
- Authentication — API keys for control-plane calls