Campaigns
Run outbound calling campaigns at scale with per-campaign telephony, scheduling, retries, circuit-breaker protection, structured event logs, and real-time progress monitoring.
What you'll learn
How to create and manage outbound calling campaigns end-to-end: CSV format, per-campaign telephony configuration, scheduling, retries, circuit-breaker protection, structured campaign event logs, and the API surface for orchestrating campaigns programmatically.
Campaigns
Campaigns let you run outbound calls at scale. You upload a CSV of phone numbers, pick the agent that makes the calls, pick which telephony configuration the campaign should dial from, configure scheduling and retry rules, and zoxaAI dials through the list automatically with concurrency control, retries, and circuit-breaker protection.
Campaign Lifecycle
A campaign moves through these states:
created --> syncing --> running --> completed
| ^
| |
(paused | failed) --> running (resume)| State | Description |
|---|---|
created | Campaign is configured but not started. Settings can still be edited. |
syncing | The uploaded CSV is being processed and contacts are being queued. |
running | Campaign is actively dialing contacts. |
paused | Campaign is temporarily stopped. Can be resumed. In-progress calls complete normally; no new calls are initiated. |
completed | Every contact has reached a final outcome (including retries). |
failed | Campaign hit an unrecoverable setup error — a CSV sync failure that cannot be recovered. Still resumable once fixed. |
A campaign can be paused manually from the detail page, automatically by the circuit breaker when the recent failure rate crosses your threshold, or by the failure policy after repeated batch trouble.
Exact completion
A campaign finishes the moment its last call's outcome lands — not on a timer. As soon as no contact is still queued, dialing, or waiting on a call outcome, the campaign moves to completed. There's no inactivity wait; a finished campaign shows completed within moments of the final call ending.
Self-healing
If a worker crashes mid-batch, contacts it had claimed aren't lost. A background sweep recovers them automatically — requeuing contacts that never dialed, and resolving the outcome of calls whose provider callback went missing — and records what it did as an entry in the campaign's event log. You don't need to intervene; the campaign still reaches exact completion.
Pause vs fail
Transient trouble while dialing pauses the campaign rather than failing it:
- If a batch errors, it's retried. After 3 consecutive batch failures, the campaign pauses with an Activity entry explaining why.
- If the campaign's phone-number pool has no free number, dispatch backs off and retries up to 3 consecutive batches; the 3rd consecutive miss pauses the campaign.
- The circuit breaker pauses the campaign when the failure rate spikes (unchanged).
A paused campaign can be resumed once the underlying issue clears — resume revalidates your wallet balance and telephony configuration first. failed is reserved for unrecoverable setup errors and is likewise resumable.
Creating a Campaign
Campaign creation is a four-step wizard. Your choices are kept in the browser as you go and only saved when you finish the last step — nothing is created until then.
Basics & target
Give the campaign a name (1-255 characters) and pick the agent that makes the calls. The picker is searchable. Choose which telephony configuration the campaign dials from (defaulted to your org's default, changeable here).
Contacts
Upload your CSV. The wizard parses it in the browser and previews what it found before anything is uploaded: detected columns, row count, and warnings for a missing phone_number column, numbers without a leading +, and duplicate numbers. You can't move on while a hard error (like a missing phone_number column) is unresolved; the server revalidates on submit as the final authority.
Call settings
Set concurrency, retry rules (including backoff and per-reason delays), scheduling windows and timezone, and the circuit breaker. Everything here is optional with sensible defaults.
Review & launch
A summary of every choice, plus the contact count and your wallet balance. Create the campaign — it lands on its detail page in created state. It does not start calling until you press Start.
One agent per campaign
Every campaign targets exactly one agent. Over the API, pass the agent's public uuid as agent_uuid; the campaign response reports it back as agent_id (internal integer id) and agent_name.
API Example: Create a Campaign
curl -X POST https://dashboard.zoxa.ai/api/v1/campaign/create \
-H "X-API-Key: zsk_..." \
-H "Content-Type: application/json" \
-d '{
"name": "January Outreach",
"agent_uuid": "550e8400-e29b-41d4-a716-446655440000",
"source_type": "csv",
"source_id": "campaigns/1/abc123_contacts.csv",
"telephony_configuration_id": 7,
"max_concurrency": 5,
"retry_config": {
"enabled": true,
"max_retries": 2,
"retry_delay_seconds": 300,
"retry_on_busy": true,
"retry_on_no_answer": true,
"retry_on_voicemail": false
},
"schedule_config": {
"enabled": true,
"timezone": "America/New_York",
"slots": [
{"day_of_week": 0, "start_time": "09:00", "end_time": "17:00"},
{"day_of_week": 1, "start_time": "09:00", "end_time": "17:00"},
{"day_of_week": 2, "start_time": "09:00", "end_time": "17:00"},
{"day_of_week": 3, "start_time": "09:00", "end_time": "17:00"},
{"day_of_week": 4, "start_time": "09:00", "end_time": "17:00"}
]
},
"circuit_breaker": {
"enabled": true,
"failure_threshold": 0.5,
"window_seconds": 120,
"min_calls_in_window": 5
}
}'Telephony Configuration per Campaign
Every campaign is bound to a specific telephony configuration — the provider account (Twilio, Vonage, Telnyx, Cloudonix, Vobiz, or Asterisk ARI) and the pool of outbound phone numbers it owns.
You can pick this explicitly at campaign-create time, or leave it blank and zoxaAI will use your organization's default telephony configuration. The chosen configuration is persisted on the campaign row and used for every call in that campaign.
Why this matters
The from-number pool is keyed per (organization, telephony_configuration_id). Two campaigns running on different telephony configs draw from independent caller-ID pools, so a Twilio campaign and a Telnyx campaign running in parallel never share or steal each other's outbound numbers.
You can change the telephony configuration on a campaign before it starts running by editing the campaign — once it's in running state, the binding is fixed.
CSV Format
Upload a CSV file with your contact data. The file must include a phone_number column.
Required Column
| Column | Description |
|---|---|
phone_number | The phone number to call. E.164 format recommended (e.g., +14155552671). |
Extra Columns as Context Variables
Any additional columns in the CSV become context variables available in your agent's system prompt during the call. For example:
phone_number,first_name,account_id,appointment_date
+14155552671,Sarah,ACC-1234,2026-06-15
+14155552672,James,ACC-5678,2026-06-16
+14155552673,Maria,ACC-9012,2026-06-17In this example, first_name, account_id, and appointment_date are all available as context variables during each call. Reference them in your agent's system prompt using template syntax: {{first_name}}, {{account_id}}, {{appointment_date}}.
Header and Variable Normalization
Column headers are normalized to lowercase with underscores — leading/trailing spaces are trimmed, letters are lowercased, and internal spaces become underscores. So a First Name header becomes the context key first_name.
Variable matching in your agent's prompt is normalization-insensitive: the {{variable}} name is normalized the same way before lookup, so {{First Name}}, {{first name}}, and {{first_name}} all resolve against a First Name (→ first_name) column. Write your variables however reads best — the case and spaces don't have to match the header exactly.
Check your column names
Campaign creation validates the phone_number column and the CSV format. It does not check the other columns against the {{variables}} in your agent's prompt, so make sure every variable your prompt uses has a matching column.
Starting a Campaign
After creating a campaign, it remains in created state until you explicitly start it.
Via the Dashboard
- Go to the campaign detail page.
- Click Start.
- The campaign transitions to
syncingwhile your CSV data is processed. - Once synced, it transitions to
runningand begins making calls.
Via the API
curl -X POST https://dashboard.zoxa.ai/api/v1/campaign/{campaign_id}/start \
-H "X-API-Key: zsk_..."Pre-Start Checks
Before starting, zoxaAI checks:
| Check | Error if Failed |
|---|---|
| At least one telephony configuration exists | 400: You must configure telephony first |
| Account has available calling quota | 402: Monthly call minutes quota exceeded |
Campaign is in created state | 400: Cannot start a {state} campaign |
Concurrency Limits
The Max Concurrent Calls (max_concurrency) setting controls how many calls the campaign runs simultaneously.
| Constraint | Value |
|---|---|
| Minimum | 1 |
| Maximum | 100 |
| Effective max | Min(organization concurrency limit, phone number count in the campaign's telephony config) |
If the campaign's telephony configuration has 3 phone numbers, the effective max concurrency is 3 — each concurrent call uses a different outbound number as its caller ID.
Concurrency and phone numbers
Your effective max concurrency is capped by the number of phone numbers registered on the campaign's telephony configuration. Add more phone numbers to that configuration to increase concurrency, or pick a different telephony configuration with a larger pool.
Retry policy
Configure automatic retries for calls that don't connect. Each retry is a fresh dial attempt on the same contact, preserving its context variables, and is tracked as part of the contact's retry chain.
| Field | Type | Default | Range | Description |
|---|---|---|---|---|
enabled | boolean | true | — | Toggle retries on or off. |
max_retries | integer | 2 | 0-10 | Maximum retry attempts per contact. |
retry_delay_seconds | integer | 120 | 30-3600 | Base wait time between attempts (seconds). |
retry_on_busy | boolean | true | — | Retry when the line is busy. |
retry_on_no_answer | boolean | true | — | Retry when there is no answer. |
retry_on_voicemail | boolean | true | — | Retry when voicemail is detected. |
backoff_multiplier | float | 1.0 | 1.0-5.0 | 1.0 = fixed delay; higher spaces retries out exponentially. |
retry_delay_cap_seconds | integer | 3600 | 60-7200 | Hard ceiling on any single computed delay. |
busy_delay_seconds | integer | — | 30-3600 | Per-reason base delay for busy (overrides retry_delay_seconds). |
no_answer_delay_seconds | integer | — | 30-3600 | Per-reason base delay for no-answer. |
voicemail_delay_seconds | integer | — | 30-3600 | Per-reason base delay for voicemail. |
How the delay is computed
For retry attempt N (1-based — the first retry is attempt 1):
base = per-reason override for this reason, else retry_delay_seconds
delay = base × backoff_multiplier^(N-1)
delay = min(delay, retry_delay_cap_seconds)When backoff_multiplier > 1.0, ±20% random jitter is added so retries don't stampede. With the default backoff_multiplier of 1.0, every retry waits the same base — a plain fixed delay.
Worked example
With retry_delay_seconds: 120, backoff_multiplier: 2.0, retry_delay_cap_seconds: 3600:
- Attempt 1 (first retry):
120 × 2^0= ~120 s (±20%) - Attempt 2:
120 × 2^1= ~240 s (±20%) - Attempt 3:
120 × 2^2= ~480 s (±20%)
Each contact gets progressively more breathing room before the next try. If a no_answer_delay_seconds of 300 were set, no-answer retries would use 300 as their base instead of 120.
Voicemail retries
retry_on_voicemail relies on the agent's voicemail detection — the pipeline detects the answering machine on the first turn, ends the call, and schedules a retry when the toggle is on. Agent voicemail detection is on by default; you can turn it off per agent in the agent editor's Call settings.
Scheduling
Scheduling restricts when the campaign makes calls, so you only dial during appropriate hours.
Time Slot Fields
| Field | Type | Description |
|---|---|---|
day_of_week | integer (0-6) | 0 = Monday, 1 = Tuesday, ..., 6 = Sunday |
start_time | string (HH:MM) | When dialing begins (24-hour format, e.g., 09:00) |
end_time | string (HH:MM) | When dialing stops (e.g., 17:00). Must be after start_time. |
Schedule Configuration Fields
| Field | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Toggle scheduling on or off |
timezone | string | "UTC" | IANA timezone (e.g., America/New_York, Asia/Kolkata, Europe/London) |
slots | array | — | 1-50 time slot objects |
Outside of scheduled time windows, the campaign orchestrator pauses batch dispatch but does not change the campaign state. Calls resume automatically in the next window.
Example: Weekday Business Hours
{
"enabled": true,
"timezone": "America/New_York",
"slots": [
{"day_of_week": 0, "start_time": "09:00", "end_time": "17:00"},
{"day_of_week": 1, "start_time": "09:00", "end_time": "17:00"},
{"day_of_week": 2, "start_time": "09:00", "end_time": "17:00"},
{"day_of_week": 3, "start_time": "09:00", "end_time": "17:00"},
{"day_of_week": 4, "start_time": "09:00", "end_time": "17:00"}
]
}Circuit Breaker
The circuit breaker automatically pauses the campaign if too many calls fail in a short period. This protects against wrong-number lists, provider outages, or misconfigured agents.
Configuration Fields
| Field | Type | Default | Range | Description |
|---|---|---|---|---|
enabled | boolean | true | — | Toggle the circuit breaker on or off |
failure_threshold | float | 0.5 | 0.0-1.0 | Pause when this fraction of recent calls fail (0.5 = 50%) |
window_seconds | integer | 120 | 30-600 | Sliding window duration to measure failure rate |
min_calls_in_window | integer | 5 | 1-100 | Minimum calls in the window before the threshold is evaluated |
How It Works
The circuit breaker uses a sliding window backed by Redis sorted sets:
- Every call outcome (success or failure) is recorded with a timestamp.
- Outcomes older than the rolling window are discarded.
- When the number of calls in the window reaches
min_calls_in_window, the failure rate is calculated. - If the failure rate exceeds
failure_threshold, the campaign is automatically paused.
When the breaker trips:
- The campaign state changes to
paused. - A
circuit_breaker_trippedentry is written to the campaign's structured logs, including the last 20 failures (call id, reason, timestamp). You can see this on the detail page without digging through server logs. - A real-time event is published for monitoring dashboards.
When you resume a paused campaign, the circuit-breaker state is reset to give the campaign a clean start.
Campaign Event Logs
Every campaign has an append-only logs[] array surfaced in the API response and on the detail page. Operators use it to understand why a campaign moved to paused, failed, or got stuck — without needing access to server logs.
Each entry has a timestamp, level, event name, message, and structured details.
Common Events
| Event | When it fires | Details captured |
|---|---|---|
circuit_breaker_tripped | Failure rate crossed failure_threshold | Window stats + last 20 failures (run id, reason, timestamp) |
phone_number_pool_exhausted_retry | Dispatch ran with no free outbound numbers; bounded retry in progress | Attempt number + max attempts |
phone_number_pool_exhausted | All bounded retries exhausted; campaign pauses | Final attempt count |
batch_failure_retry | A batch errored; bounded retry in progress | Error message |
batch_failures_paused | 3 consecutive batch failures; campaign pauses | Error message |
stuck_rows_swept | Self-healing recovered contacts a crashed worker stranded | Recovered / advanced counts |
stale_call_resolved | A call whose status callback never arrived was resolved | Affected contact ids |
campaign_archived / campaign_unarchived | Campaign hidden / restored | — |
| Source-sync failures | CSV could not be parsed or had a schema issue | Underlying error message |
Phone-pool exhaustion pauses, not fails
If the campaign's telephony configuration has no free outbound number at dispatch time, the campaign does not fail. It retries up to 3 consecutive batches (on the normal batch cadence), logging each attempt as phone_number_pool_exhausted_retry. After the third miss it pauses with a phone_number_pool_exhausted log — resume it once numbers free up.
Contact tracking
Every contact carries a ledger — a record of each dial attempt made against it. The first dial is the root attempt; each retry is a chained attempt with its own outcome. This is what powers the Contacts view and the funnel: you can see exactly what happened on every try for every person in the list.
Each attempt records a call outcome, one of:
| Outcome | Meaning |
|---|---|
completed | The call ran to its natural end (a human was reached). |
busy | The line was busy. |
no_answer | Nobody picked up. |
voicemail | The call reached voicemail. |
failed | The call failed (or dispatch failed before it reached the carrier). |
canceled | The call was canceled. |
The contact itself rolls up to a single status based on its latest attempt — pending, dialing, in_call, retry_scheduled, completed, busy, no_answer, voicemail, failed, or canceled. A contact that completes on any attempt stays completed.
Read contacts programmatically with GET /campaign/{id}/contacts — it paginates over contacts and expands each one's full retry chain.
Insights
The Insights view aggregates the whole campaign live in a single request — no waiting on a nightly rollup. GET /campaign/{id}/insights returns:
- Funnel — contacts → dialed → connected → completed (contact-level, so retries don't double-count).
- Outcome breakdown — how many attempts hit each outcome, plus live in-motion counts (pending, dialing, in-flight, scheduled retries).
- Retry effectiveness — how many contacts completed on each attempt number, conversion rate per retry reason, and the list of upcoming retries.
- Cost & duration — total and average cost, cost per connected contact, and a call-duration histogram, aggregated over completed calls.
- Timeline & pacing — calls dialed and connected over time (bucketed by minute / hour / day depending on how long the campaign has run), alongside the configured dial rate and current in-flight count.
Live updates
The campaign detail page updates live while a campaign runs — no manual refresh. It opens a WebSocket to /campaign/{id}/events and applies changes as they happen: contact rows flip status as outcomes land, the Activity log appends new entries, and the Overview counters tick.
The model is snapshot-plus-deltas: the page loads the authoritative state over REST, then the socket streams change signals. If the connection drops, it reconciles by refetching — so a missed event is never a problem. You can build the same behavior into your own tooling; see the WebSocket reference for the event catalog and a connect example.
Monitoring a campaign
The campaign detail page has a sidebar with five sections:
| Section | What it shows |
|---|---|
| Overview | Live command center: progress (dialed / total), in-flight now, connected, retries pending, failed, a streaming feed of recent outcomes, and target / telephony / source summary with a CSV download. |
| Contacts | The per-contact ledger — one row per contact, expandable to its full retry chain. Filter by status, search by phone. Rows update live. |
| Calls | The per-call results table with the usual filters (status, disposition, duration, date), deep-linking to each call's detail. |
| Insights | The analytics above, as charts: funnel, outcome donut, retry effectiveness, cost & duration, and a calls-over-time timeline with pacing. |
| Activity | The structured event log as a timeline, newest first, with expandable details (including circuit-breaker trip evidence). Appends live. |
Via the API
For a lightweight progress poll, use GET /campaign/{id}/progress:
curl https://dashboard.zoxa.ai/api/v1/campaign/{campaign_id}/progress \
-H "X-API-Key: zsk_..."For the deeper views, use GET /campaign/{id}/contacts and GET /campaign/{id}/insights. Per-call results (status, ended reason, duration, call id) come from GET /campaign/{id}/runs.
Archiving
Archiving hides a campaign from the default list without deleting anything — all its contacts, calls, insights, and logs stay intact, and you can unarchive at any time.
- Archive a non-active campaign to declutter your list. A
runningorsyncingcampaign must be paused first (the dashboard offers a combined "Pause & archive"). - Archived campaigns can't be edited, started, or resumed until you unarchive them.
- The list has a Show archived toggle; the API exposes archived campaigns via
GET /campaign?archived=true.
# Archive
curl -X POST https://dashboard.zoxa.ai/api/v1/campaign/{campaign_id}/archive \
-H "X-API-Key: zsk_..."
# Unarchive
curl -X POST https://dashboard.zoxa.ai/api/v1/campaign/{campaign_id}/unarchive \
-H "X-API-Key: zsk_..."Archive is always reversible
zoxaAI never destructively deletes a campaign. Archiving is a soft hide — unarchive restores the campaign exactly as it was.
Pausing and Resuming
Pause
Pausing stops the campaign from initiating new calls. Calls already in progress complete normally.
curl -X POST https://dashboard.zoxa.ai/api/v1/campaign/{campaign_id}/pause \
-H "X-API-Key: zsk_..."Resume
Resuming continues the campaign from where it left off. It works on both paused and failed campaigns — both are recoverable. The circuit-breaker window is reset on resume, and the same pre-start checks run first (telephony configured, wallet above the start threshold, campaign not archived).
curl -X POST https://dashboard.zoxa.ai/api/v1/campaign/{campaign_id}/resume \
-H "X-API-Key: zsk_..."Updating a Campaign
Update campaign settings while it is in created, syncing, running, or paused state. Completed and failed campaigns cannot be updated, and an archived campaign must be unarchived first (returns 409).
curl -X PATCH https://dashboard.zoxa.ai/api/v1/campaign/{campaign_id} \
-H "X-API-Key: zsk_..." \
-H "Content-Type: application/json" \
-d '{
"name": "Updated Campaign Name",
"max_concurrency": 10,
"telephony_configuration_id": 9,
"retry_config": {
"enabled": true,
"max_retries": 3,
"retry_delay_seconds": 300,
"retry_on_busy": true,
"retry_on_no_answer": true,
"retry_on_voicemail": true
}
}'Updatable Fields
| Field | Description |
|---|---|
name | Campaign name (1-255 characters) |
telephony_configuration_id | Switch the campaign to a different telephony configuration (before it enters running) |
retry_config | Full retry configuration object |
max_concurrency | Max concurrent calls (1-100, capped by effective limit) |
schedule_config | Full schedule configuration object |
circuit_breaker | Full circuit breaker configuration object |
Target and source are locked
You cannot change the campaign's target agent (agent_id) or its CSV after creation. Create a new campaign if you need to change those.
Campaign Runs
View individual call results for a campaign with pagination and filtering.
curl "https://dashboard.zoxa.ai/api/v1/campaign/{campaign_id}/runs?limit=50&offset=0&status=completed" \
-H "X-API-Key: zsk_..."Each run record includes the contact's row data (the original CSV row, used for template variables), the call id, status, ended reason, duration, and timestamps. Use this endpoint — or the detail page's runs table — to drill into individual call outcomes.
Campaign Response Schema
All campaign endpoints return the same response shape:
| Field | Type | Description |
|---|---|---|
id | integer | Campaign ID |
name | string | Campaign name |
agent_id | integer | Target agent ID (internal integer id; send the agent's uuid as agent_uuid on create) |
agent_name | string | Target agent name |
state | string | Current campaign state |
source_type | string | Always "csv" |
source_id | string | CSV file storage key |
telephony_configuration_id | integer or null | Telephony configuration the campaign dials from |
telephony_configuration_name | string or null | Human-friendly name of the telephony configuration |
total_rows | integer or null | Total contacts in the CSV |
processed_rows | integer | Calls completed so far |
failed_rows | integer | Contacts that failed after all retries |
created_at | datetime | When the campaign was created |
started_at | datetime or null | When the campaign started running |
completed_at | datetime or null | When the campaign finished |
retry_config | object | Current retry configuration |
max_concurrency | integer or null | Max concurrent calls |
schedule_config | object or null | Schedule configuration |
circuit_breaker | object or null | Circuit breaker configuration |
executed_count | integer | Number of calls executed |
total_queued_count | integer | Total calls queued |
connected_count | integer | Contacts reached at least once (list view only) |
retrying_count | integer | Contacts with a future retry pending (list view only) |
failed_contact_count | integer | Contacts that finished without connecting (list view only) |
in_flight_count | integer | Attempts dispatched, awaiting an outcome (list view only) |
archived_at | datetime or null | Set once archived; null for live campaigns |
logs | array | Structured event log entries (see above) |
The four *_count fields are contact-level and populated only by the list endpoint; single-campaign responses return them as 0.
Requirements
Before starting a campaign, ensure:
- At least one telephony configuration is set up with phone numbers.
- The target agent exists in your organization.
- Your account has available calling quota.
- If using scheduling, at least one time slot must be defined.