# Campaigns

Source: https://docs.usetone.ai/campaigns

> Call a list of people — pacing, retries, the legal calling window, and the pre-flight that tells you what will happen before it does.

A campaign calls a list of people with one agent, from one number, at a pace you set. It handles
concurrency, retries, the calling window, and the compliance checks on every single dial.

Use `POST /v1/calls` when your own system decides who to call and when — an order ships, a payment
fails, a customer clicks "call me". Use a campaign when you have a list and want it worked through.

A campaign is not a second dialling path. Every call it places goes through the same pre-dial
compliance gate, the same CDR, the same pricing and the same webhooks as a call you place yourself.
There is deliberately no way to dial from here that skips any of it.

Everything below runs in [test mode](/test-mode) for free. Use the
[magic numbers](/test-mode) as recipients and you get deterministic outcomes to assert on.

## Before you start

Two things must be true, and both are checked at **launch** rather than at create — so you can
build a draft campaign long before either is ready:

- **The agent is live.** A draft agent gets you `422` — *"… is still a draft. Set it live before
  launching a campaign with it."*
- **The number routes to a Tone agent** (`routingMode: "tone_agent"`). A
  [BYO number](/quickstart-byo) is refused at launch, not per dial: its calls are answered by
  your stack, so a campaign on one would connect thousands of people to infrastructure this
  campaign's agent never touches. You would otherwise discover that as several thousand mid-run
  errors instead of one at the button.

## 1. Create the campaign

It starts as a `draft`. Nothing dials until you launch.

```bash
curl "$TONE_API/v1/campaigns" \
  -X POST \
  -H "Authorization: Bearer $TONE_KEY" \
  -H 'content-type: application/json' \
  -d '{
    "name": "COD confirmations — March",
    "agentId": "'"$AGENT"'",
    "numberId": "'"$NUMBER"'",
    "callsPerMinute": 30,
    "maxConcurrent": 5,
    "retryAttempts": 2,
    "retryIntervalMinutes": 30,
    "retryBackoff": "linear",
    "retryOn": ["no_answer", "busy"],
    "windowStartMinute": 600,
    "windowEndMinute": 1140,
    "weekdays": 31,
    "autoPauseBelowPaise": 50000
  }'
```

```json
{
  "data": {
    "id": "3b7e9a41-...",
    "name": "COD confirmations — March",
    "status": "draft",
    "environment": "test",
    "agent": { "id": "a4f21c8e-...", "name": "Order confirmation" },
    "number": { "id": "9c1d0f22-...", "e164": "+918041234567" },
    "purpose": null,
    "schedule": {
      "startsAt": null, "endsAt": null,
      "windowStartMinute": 600, "windowEndMinute": 1140, "weekdays": 31
    },
    "pacing": { "callsPerMinute": 30, "maxConcurrent": 5 },
    "retries": { "attempts": 2, "intervalMinutes": 30, "backoff": "linear", "on": ["no_answer", "busy"] },
    "autoPauseBelowPaise": 50000,
    "progress": { "total": 0, "dialed": 0, "connected": 0, "excluded": 0, "spentPaise": 0 },
    "preflight": null,
    "launchedAt": null, "completedAt": null, "pausedReason": null, "archivedAt": null
  }
}
```

Save the `id` as `$CAMPAIGN`.

### The fields worth understanding

**The calling window is IST minutes from midnight.** `600` is 10:00, `1140` is 19:00. Not a
timezone-less time and not your server's zone: every recipient and every obligation here is
Indian, and the gate that blocks calls resolves in `Asia/Kolkata`. `weekdays` is a 7-bit mask with
Monday as bit 0, so `31` is Mon–Fri and `127` is every day. `0` is rejected — a campaign with no
enabled day can never dial, and accepting it would leave it `running` and silently idle.

**`callsPerMinute` caps at 60 and `maxConcurrent` at 20.** The first mirrors the carrier's own
campaign throttle, and bursts from one caller ID are exactly the shape TCCCPR's spam rubric
watches for. The second is the provider's per-account speech limit, shared across every one of
your API keys — a higher number would exhaust the provider before it exhausted anything of ours.

**`retryOn` cannot contain `answered`.** Redialling someone who already spoke to the agent is what
turns a campaign into a complaint, and under TCCCPR five complaints in ten days bars *every*
number your organization owns. The legal values are `no_answer`, `busy`, `failed` and `voicemail`.

**`autoPauseBelowPaise` is a floor, not a reservation.** When the wallet drops below it the
campaign pauses itself with `pausedReason: "low_balance"` and emits `campaign.paused`. `0` turns
the floor off, which means the campaign runs until the wallet is empty.

**`purpose` is null until launch.** It is snapshotted from the agent at that moment and never
refreshed, because it decides which calling window applied. A campaign that ran at 20:00 under a
`service` agent must keep saying `service` after someone flips that agent to `promotional` — the
alternative is a compliance record that retroactively accuses you of a violation you did not
commit.

## 2. Add recipients

Up to 500 per request. `variables` are the values substituted into your agent's prompt.

```bash
curl "$TONE_API/v1/campaigns/$CAMPAIGN/recipients" \
  -X POST \
  -H "Authorization: Bearer $TONE_KEY" \
  -H 'content-type: application/json' \
  -d '{
    "recipients": [
      { "e164": "+915555000001", "variables": { "order_id": "A-4471", "amount": "1499" } },
      { "e164": "+915555000002", "variables": { "order_id": "A-4472", "amount": "899" } }
    ]
  }'
```

```json
{ "data": { "added": 2, "duplicates": 0, "total": 2 } }
```

**Retrying a batch is safe.** A number already on the campaign counts as a `duplicate` and is
skipped, not an error — so a client that never saw its response can simply send it again. That is
why this endpoint does not need an `Idempotency-Key`.

A campaign holds at most **20,000 recipients**; past that you get a `422` telling you to split the
list.

Recipients belong to the campaign, not to a shared contact list. Re-targeting the same audience is
`POST /v1/campaigns/{id}/duplicate`, which copies the campaign and its recipients back to a draft.

## 3. Run the pre-flight

The pre-flight tells you what will happen before anything dials: who gets excluded and why, what
the compliance checks say, and what it will cost.

```bash
curl "$TONE_API/v1/campaigns/$CAMPAIGN/preflight" \
  -X POST -H "Authorization: Bearer $TONE_KEY"
```

```json
{
  "data": {
    "preflight": {
      "computedAt": "2026-03-04T09:12:44.183Z",
      "total": 2000,
      "excluded": { "invalid_number": 3, "dnc": 12, "frequency_cap": 51, "duplicate": 0 },
      "callable": 1934,
      "checks": [
        { "type": "dlt", "outcome": "pass", "reason": "not_applicable" },
        { "type": "time_window", "outcome": "pass", "reason": null },
        { "type": "dnc", "outcome": "pass", "reason": null },
        { "type": "dnd_scrub", "outcome": "warn", "reason": "no_scrubber_configured" }
      ],
      "estimatedCostPaise": { "low": 570000, "high": 1140000 },
      "walletBalancePaise": 2500000
    }
  }
}
```

It is a `POST` because it computes and caches — but it places no calls and changes nothing else.
Launch is a separate, deliberate second step.

Each recipient is counted against exactly **one** reason — the first that applies — so the buckets
sum to `total`. Someone both unreachable and suppressed is reported as `invalid_number`, the more
specific fact and the one you can act on.

⚠️ **`duplicate` is always `0` here.** Duplicates never become recipient rows: the unique index on
`(campaign, number)` drops them at insert. The count you want is the `duplicates` field in the
add-recipients response — record it there, because the pre-flight cannot recompute what was never
stored.

**Read the `checks` before you launch.** A `block` makes launch a `422`; a `warn` does not, and is
information you are expected to act on. `no_scrubber_configured` is the honest answer when no
authoritative DND scrubber is wired up — it is never recorded as a pass.

A fresh pre-flight run also returns `excludedE164s`, grouping the excluded numbers by reason so you
can export them. It is never stored, so a later `GET /v1/campaigns/{id}` returns the counts alone.

## 4. Launch

```bash
curl "$TONE_API/v1/campaigns/$CAMPAIGN/launch" \
  -X POST \
  -H "Authorization: Bearer $TONE_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
```

**`Idempotency-Key` is required here**, not merely honored. Without it you get a `400` naming the
header. A duplicate launch is a second pass over your recipient list: real people called twice,
which is a compliance problem before it is a billing one. Send the *same* key if a request times
out and you'll get the first result back rather than a second run — see
[Idempotency](/idempotency).

The campaign moves to `running` and the runner starts dialling within its window and pace.

## 5. Watch it

**Subscribe to webhooks** rather than polling. `call.answered`, `call.completed` and `call.failed`
arrive per call; `campaign.paused` and `campaign.completed` cover the run itself — including the
automatic wallet-floor pause, which is the one nobody thinks to watch for. See
[Webhooks](/webhooks).

Every call a campaign places carries **`campaignId`** on the call resource — in the webhook payload
and on `GET /v1/calls/{id}` alike — so attributing an event is a field read even with several
campaigns running at once. A call you placed yourself has `campaignId: null`.

```json
{ "id": "403cb084-...", "campaignId": "99ba451d-...", "disposition": "answered", "...": "..." }
```

Retries are attributed the same way: each attempt is its own call row carrying the same
`campaignId`, so you see all of them, not just the last. The same id filters the call log:

```bash
curl -H "Authorization: Bearer $TONE_KEY" "$TONE_API/v1/calls?campaign_id=$CAMPAIGN&limit=100"
```

`GET /v1/calls/export.csv` takes it too, so a campaign's full call history — every attempt, with
durations and costs — is one request.

**Poll the campaign** for the cached counters:

```bash
curl -H "Authorization: Bearer $TONE_KEY" "$TONE_API/v1/campaigns/$CAMPAIGN"
```

```json
{ "data": { "status": "running", "progress": {
  "total": 2000, "dialed": 1240, "connected": 812, "excluded": 100, "spentPaise": 486300 } } }
```

**Read the outcomes** for the funnel, including what your agent actually established:

```bash
curl -H "Authorization: Bearer $TONE_KEY" "$TONE_API/v1/campaigns/$CAMPAIGN/outcomes"
```

```json
{
  "data": {
    "status": { "done": 1240, "pending": 660, "excluded": 100 },
    "disposition": { "answered": 812, "no_answer": 301, "busy": 88, "voicemail": 39 },
    "excludedReason": { "duplicate": 34, "dnc": 12, "invalid_number": 3, "frequency_cap": 51 },
    "agentOutcome": {
      "delivery_confirmed": { "yes": 604, "no": 122 },
      "preferred_day": { "thursday": 288, "friday": 201, "saturday": 96 }
    }
  }
}
```

`agentOutcome` is the part worth building on. The keys and values are your agent's own declared
output variables, so "812 connected" becomes "604 confirmed, 122 declined" with no configuration
at all. It counts answered calls only, so *connected minus tallied* is the calls where extraction
found no clear answer.

**Per-recipient detail** is `GET /v1/campaigns/{id}/recipients`, filterable by `status`,
`disposition` and `excluded_reason`, cursor-paginated like every other list. Each row carries
`lastCallId`, which joins to `GET /v1/calls/{id}` for the transcript and recording. It names the
*last* attempt only — for every attempt, use `GET /v1/calls?campaign_id=`. For the whole recipient
list at once there is `GET /v1/campaigns/{id}/export.csv`, which includes the outcomes.

## 6. Pause, resume, stop

```bash
curl -X POST -H "Authorization: Bearer $TONE_KEY" "$TONE_API/v1/campaigns/$CAMPAIGN/pause"
curl -X POST -H "Authorization: Bearer $TONE_KEY" "$TONE_API/v1/campaigns/$CAMPAIGN/resume"
curl -X POST -H "Authorization: Bearer $TONE_KEY" "$TONE_API/v1/campaigns/$CAMPAIGN/stop"
```

`paused` is resumable and is what both your Pause and the wallet floor produce. `stopped` is
terminal and abandons the remaining recipients. They are deliberately different states, because
otherwise "why did this stop overnight?" has no answer — check `pausedReason` for it: `operator`,
`low_balance`, `target_unavailable` or `carrier_errors`.

Calls already in flight are not cut off by a pause; it stops new dials.

## The rules that bite

**The calling window must fit your purpose — including the retry tail.** `promotional` is
09:00–21:00 IST (TCCCPR's preference band) and `collections` is 08:00–19:00 (RBI's recovery-agent
circular, tighter than anything TRAI imposes). `service` and `transactional` have no window.

The pre-flight checks that your window *plus the retry tail* fits inside the legal one. Two retries
at 30 minutes is a 60-minute tail, so a promotional campaign may end no later than 20:00. A window
ending at 20:55 with that ladder would place its last retry at 21:55 — outside the band, and the
single easiest violation to prove. You get `time_window: block` with reason
`retry_tail_outside_calling_window`, and launch refuses.

**`maxConcurrent` above your quota does not queue.** The overflow is refused and those recipients
settle as failed. Check `GET /v1/limits` for your real ceiling before raising it.

**The agent can never be changed after create.** `PATCH` refuses `agentId` outright. Changing the
agent changes the purpose, which changes the legal window — and a campaign that dialled half its
list under one window and half under another produces a compliance record nobody can read, which
is the record's whole job. Point a new campaign at the other agent. Everything else freezes too
once the campaign leaves `draft`/`scheduled`.

**Recipients are excluded, not silently dropped.** `invalid_number`, `dnc`, `frequency_cap`,
`dnd_scrub` and `blocked_by_gate` each appear in the funnel with a count, and every excluded
recipient keeps its row with an `excludedReason` you can filter on.

**The frequency cap spans campaigns, not just this one.** Anyone your organization called in the
recent window is excluded, because two overlapping campaigns calling the same person in a week is
exactly the complaint pattern TCCCPR reg. 25 counts against you.

## Compliance evidence

```bash
curl -H "Authorization: Bearer $TONE_KEY" "$TONE_API/v1/campaigns/$CAMPAIGN/compliance"
```

Every check the gate ran, for every attempt, in the order it ran them — passes included, not just
refusals. This is the artefact you hand your access provider when a complaint is investigated.
Under TCCCPR reg. 25, five complaints in ten days bars every telecom resource a sender owns
"irrespective of whether those telecom resources were actually used", so the defence is a
per-attempt record of what was checked and what it returned. A log of only refusals proves nothing
about the calls that went through — which are exactly the calls being complained about.

For one number rather than one campaign, `GET /v1/compliance/evidence?e164=` assembles the same
evidence across checks, consent history and suppression. See
[Compliance as an API](/quickstart-compliance).

## Where to go next

- **[Webhooks](/webhooks)** — the event catalog and how to verify a signature.
- **[Test mode](/test-mode)** — magic numbers that give a campaign deterministic outcomes.
- **[Errors](/errors)** — the `blocked_*` family, `insufficient_funds`, and
  `concurrent_call_limit_reached`.
