# Bulk Campaigns

> Start, pace, list, and abort CSV-driven outbound campaigns with startCampaign, listCampaigns, and abortCampaign.

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:

| Procedure                    | Kind     | What it does                                        |
| ---------------------------- | -------- | --------------------------------------------------- |
| `telephony.startCampaign`    | mutation | Persists the campaign + list, hands the template to the engine. |
| `telephony.listCampaigns`    | query    | Returns every campaign record for the org.          |
| `telephony.abortCampaign`    | mutation | Tells the engine to stop the campaign, marks it `aborted`. |

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.

| Field                  | Written by      | Meaning                                               |
| ---------------------- | --------------- | ----------------------------------------------------- |
| `campaign_id`          | you             | Your id for the run. Required, non-empty. The console generates one per launch. |
| `name`                 | you             | Display label. Required, non-empty.                   |
| `org_id`               | `startCampaign` | Owning org, copied from `orgId`.                       |
| `trunk_id`             | you             | The outbound route to originate on. Required.          |
| `call_from`            | you             | Caller ID. Optional.                                   |
| `default_app`          | you             | What an answered call bridges to. Defaults to `AI_BIDIRECTIONAL_STREAM`. |
| `default_app_args`     | you             | The argument for `default_app` — the agent id for `AI_BIDIRECTIONAL_STREAM`, the vector id for `VECTOR`. |
| `ai_websocket_url` / `ai_quic_url` | you | Optional overrides for the agent media leg.           |
| `max_duration_ms`      | you             | Optional per-call cap. Positive integer.               |
| `calls_per_second`     | you             | Pacing rate. See below.                                |
| `max_concurrent_calls` | you             | Live-call ceiling. See below.                           |
| `total_numbers`        | `startCampaign` | Length of the `numbers` array you submitted.            |
| `loaded_numbers`       | `startCampaign` | The count the engine acknowledged loading.              |
| `status`               | `startCampaign` / `abortCampaign` | See [Campaign statuses](#campaign-statuses-and-what-moves-between-them). |
| `created_at`           | `startCampaign` | ISO-8601 timestamp of the launch.                       |

`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:

```csv
+14155550123
+14155550124
+14155550125
```

A headered list works too, and the column may sit anywhere in the row:

```csv
name,to,notes
Acme,+14155550123,renewal
Globex,+14155550124,trial
```

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.

> **WARNING:**
> 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`:

| `default_app`              | `default_app_args`        |
| -------------------------- | ------------------------- |
| `AI_BIDIRECTIONAL_STREAM`  | agent id — bridges the answered call to ASR/LLM/TTS. |
| `VECTOR`                   | vector id — runs an IVR / VDN treatment.              |
| `PLAYBACK`                 | absolute audio file path on the engine.               |
| `PARK`                     | (none) — silence; bridge later.                       |
| `MUSIC_ON_HOLD`            | (none)                                                |
| `ANSWER`                   | (none) — answer and hold open.                        |
| `HANGUP`                   | (none) — hang up immediately.                         |

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:

| Value                 | Written when                                                            |
| --------------------- | ----------------------------------------------------------------------- |
| `starting`            | `startCampaign` persists the record, before the engine RPC returns.      |
| engine-reported value | `startCampaign` overwrites `status` with the engine's `status` from the bulk load ack, alongside `loaded_numbers`. In practice this is `running` for a campaign the engine accepted. |
| `aborted`             | `abortCampaign` sets it, after the engine acknowledges the abort.        |

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

```ts
await trpc.telephony.abortCampaign.mutate({
  orgId,
  campaign_id: "camp_1733900000_a1b2c3",
});
```

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](/glossary/metrics) for the
definitions of ASR, ACD, connect rate, and abandon rate, and
[Telemetry](/platform/telemetry) for the CDR schema and the metric
families to aggregate.

> **NOTE:**
> 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.

| Field                  | Constraint                                    |
| ---------------------- | --------------------------------------------- |
| `campaign_id`          | non-empty string                              |
| `name`                 | non-empty string                              |
| `trunk_id`             | non-empty string                              |
| `calls_per_second`     | positive integer, at most 200 (default `2`)   |
| `max_concurrent_calls` | positive integer, at most 2000 (default `20`) |
| `max_duration_ms`      | positive integer, optional                    |
| `numbers`              | 1 to 50000 entries, each at least 3 characters |

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

```ts
const resp = await trpc.telephony.startCampaign.mutate({
  orgId,
  campaign_id: "camp_1733900000_a1b2c3",
  name: "Q2 outbound — KA",
  trunk_id: "trunk_primary",
  call_from: "+918844001100",          // optional
  default_app: "AI_BIDIRECTIONAL_STREAM",
  default_app_args: agentId,
  calls_per_second: 2,
  max_concurrent_calls: 20,
  numbers: ["+14155550123", "+14155550124"],
});
// resp.status         → engine-reported status, also written to the record
// resp.loaded_numbers → count the engine acknowledged
```

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

```ts
const campaigns = await trpc.telephony.listCampaigns.query({ orgId });
// → Record<string, string>[], newest created_at first, [] when none
```

### abortCampaign

```ts
const resp = await trpc.telephony.abortCampaign.mutate({
  orgId,
  campaign_id,
});
```

All three are org-scoped procedures: `orgId` is required and the caller
must be authorised for that org. See
[Authentication](/concepts/authentication) for how your backend
authenticates to the control plane.

## Related

- [Telemetry](/platform/telemetry) — CDR schema and metric families for per-call outcomes
- [Telephony Metrics](/glossary/metrics) — CPS, concurrent channels, ASR, abandon rate
- [Authentication](/concepts/authentication) — API keys for control-plane calls
