A campaign is an outbound dial list plus a template that says how to dial it: which trunk to originate on, what the answered call bridges to, and how fast to pace the dial-out. You hand TeleQuick the list once, and the engine dials through it. Three procedures cover the whole surface: 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.
A minimal list needs no header at all:
A headered list works too, and the column may sit anywhere in the row:
The console reports skipped rows rather than dropping them silently (“N of M rows skipped”), and refuses to launch when zero valid numbers were found. Some builds also de-duplicate the parsed list before submitting, so 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_second is the origination rate — how quickly new calls leave the trunk. Accepted range: integer 1–200. Default 2.
  • max_concurrent_calls is the ceiling on live calls at any instant. Accepted range: integer 1–2000. Default 20.
CPS governs how fast the campaign ramps; max-concurrent governs where it plateaus. Which one binds depends on how long your calls last: a campaign with short calls drains at close to the CPS rate, while a campaign with long calls climbs at the CPS rate until it hits max-concurrent and then only dials as fast as calls hang up. Raising CPS alone does not raise steady-state throughput once max-concurrent is the binding constraint, and raising max-concurrent alone does not make the ramp faster. Both values are validated on submit (the 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.
Your carrier’s contracted CPS and channel cap are separate from these numbers and are not enforced here. Set calls_per_second and max_concurrent_calls to values your trunk is provisioned for.

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

The procedure calls the engine’s bulk abort, then unconditionally sets 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_ms expires. To end a specific in-flight call, hang that call up by its call_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.
The console asks for confirmation before calling this, then invalidates 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 from startCampaign’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

Order of operations: write the campaign hash with 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

All three are org-scoped procedures: orgId is required and the caller must be authorised for that org. See Authentication for how your backend authenticates to the control plane.