Tone docs

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 for free. Use the magic numbers 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 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.

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
  }'
{
  "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.

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" } }
    ]
  }'
{ "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.

curl "$TONE_API/v1/campaigns/$CAMPAIGN/preflight" \
  -X POST -H "Authorization: Bearer $TONE_KEY"
{
  "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

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.

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.

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.

{ "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:

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:

curl -H "Authorization: Bearer $TONE_KEY" "$TONE_API/v1/campaigns/$CAMPAIGN"
{ "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:

curl -H "Authorization: Bearer $TONE_KEY" "$TONE_API/v1/campaigns/$CAMPAIGN/outcomes"
{
  "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

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

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.

Where to go next

  • Webhooks — the event catalog and how to verify a signature.
  • Test mode — magic numbers that give a campaign deterministic outcomes.
  • Errors — the blocked_* family, insufficient_funds, and concurrent_call_limit_reached.
Was this page helpful?

On this page