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, andconcurrent_call_limit_reached.