zoxaAI
Homepage

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)
StateDescription
createdCampaign is configured but not started. Settings can still be edited.
syncingThe uploaded CSV is being processed and contacts are being queued.
runningCampaign is actively dialing contacts.
pausedCampaign is temporarily stopped. Can be resumed. In-progress calls complete normally; no new calls are initiated.
completedEvery contact has reached a final outcome (including retries).
failedCampaign 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

ColumnDescription
phone_numberThe 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-17

In 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

  1. Go to the campaign detail page.
  2. Click Start.
  3. The campaign transitions to syncing while your CSV data is processed.
  4. Once synced, it transitions to running and 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:

CheckError if Failed
At least one telephony configuration exists400: You must configure telephony first
Account has available calling quota402: Monthly call minutes quota exceeded
Campaign is in created state400: Cannot start a {state} campaign

Concurrency Limits

The Max Concurrent Calls (max_concurrency) setting controls how many calls the campaign runs simultaneously.

ConstraintValue
Minimum1
Maximum100
Effective maxMin(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.

FieldTypeDefaultRangeDescription
enabledbooleantrue—Toggle retries on or off.
max_retriesinteger20-10Maximum retry attempts per contact.
retry_delay_secondsinteger12030-3600Base wait time between attempts (seconds).
retry_on_busybooleantrue—Retry when the line is busy.
retry_on_no_answerbooleantrue—Retry when there is no answer.
retry_on_voicemailbooleantrue—Retry when voicemail is detected.
backoff_multiplierfloat1.01.0-5.01.0 = fixed delay; higher spaces retries out exponentially.
retry_delay_cap_secondsinteger360060-7200Hard ceiling on any single computed delay.
busy_delay_secondsinteger—30-3600Per-reason base delay for busy (overrides retry_delay_seconds).
no_answer_delay_secondsinteger—30-3600Per-reason base delay for no-answer.
voicemail_delay_secondsinteger—30-3600Per-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

FieldTypeDescription
day_of_weekinteger (0-6)0 = Monday, 1 = Tuesday, ..., 6 = Sunday
start_timestring (HH:MM)When dialing begins (24-hour format, e.g., 09:00)
end_timestring (HH:MM)When dialing stops (e.g., 17:00). Must be after start_time.

Schedule Configuration Fields

FieldTypeDefaultDescription
enabledbooleantrueToggle scheduling on or off
timezonestring"UTC"IANA timezone (e.g., America/New_York, Asia/Kolkata, Europe/London)
slotsarray—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

FieldTypeDefaultRangeDescription
enabledbooleantrue—Toggle the circuit breaker on or off
failure_thresholdfloat0.50.0-1.0Pause when this fraction of recent calls fail (0.5 = 50%)
window_secondsinteger12030-600Sliding window duration to measure failure rate
min_calls_in_windowinteger51-100Minimum calls in the window before the threshold is evaluated

How It Works

The circuit breaker uses a sliding window backed by Redis sorted sets:

  1. Every call outcome (success or failure) is recorded with a timestamp.
  2. Outcomes older than the rolling window are discarded.
  3. When the number of calls in the window reaches min_calls_in_window, the failure rate is calculated.
  4. 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_tripped entry 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

EventWhen it firesDetails captured
circuit_breaker_trippedFailure rate crossed failure_thresholdWindow stats + last 20 failures (run id, reason, timestamp)
phone_number_pool_exhausted_retryDispatch ran with no free outbound numbers; bounded retry in progressAttempt number + max attempts
phone_number_pool_exhaustedAll bounded retries exhausted; campaign pausesFinal attempt count
batch_failure_retryA batch errored; bounded retry in progressError message
batch_failures_paused3 consecutive batch failures; campaign pausesError message
stuck_rows_sweptSelf-healing recovered contacts a crashed worker strandedRecovered / advanced counts
stale_call_resolvedA call whose status callback never arrived was resolvedAffected contact ids
campaign_archived / campaign_unarchivedCampaign hidden / restored—
Source-sync failuresCSV could not be parsed or had a schema issueUnderlying 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:

OutcomeMeaning
completedThe call ran to its natural end (a human was reached).
busyThe line was busy.
no_answerNobody picked up.
voicemailThe call reached voicemail.
failedThe call failed (or dispatch failed before it reached the carrier).
canceledThe 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:

SectionWhat it shows
OverviewLive 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.
ContactsThe per-contact ledger — one row per contact, expandable to its full retry chain. Filter by status, search by phone. Rows update live.
CallsThe per-call results table with the usual filters (status, disposition, duration, date), deep-linking to each call's detail.
InsightsThe analytics above, as charts: funnel, outcome donut, retry effectiveness, cost & duration, and a calls-over-time timeline with pacing.
ActivityThe 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 running or syncing campaign 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

FieldDescription
nameCampaign name (1-255 characters)
telephony_configuration_idSwitch the campaign to a different telephony configuration (before it enters running)
retry_configFull retry configuration object
max_concurrencyMax concurrent calls (1-100, capped by effective limit)
schedule_configFull schedule configuration object
circuit_breakerFull 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:

FieldTypeDescription
idintegerCampaign ID
namestringCampaign name
agent_idintegerTarget agent ID (internal integer id; send the agent's uuid as agent_uuid on create)
agent_namestringTarget agent name
statestringCurrent campaign state
source_typestringAlways "csv"
source_idstringCSV file storage key
telephony_configuration_idinteger or nullTelephony configuration the campaign dials from
telephony_configuration_namestring or nullHuman-friendly name of the telephony configuration
total_rowsinteger or nullTotal contacts in the CSV
processed_rowsintegerCalls completed so far
failed_rowsintegerContacts that failed after all retries
created_atdatetimeWhen the campaign was created
started_atdatetime or nullWhen the campaign started running
completed_atdatetime or nullWhen the campaign finished
retry_configobjectCurrent retry configuration
max_concurrencyinteger or nullMax concurrent calls
schedule_configobject or nullSchedule configuration
circuit_breakerobject or nullCircuit breaker configuration
executed_countintegerNumber of calls executed
total_queued_countintegerTotal calls queued
connected_countintegerContacts reached at least once (list view only)
retrying_countintegerContacts with a future retry pending (list view only)
failed_contact_countintegerContacts that finished without connecting (list view only)
in_flight_countintegerAttempts dispatched, awaiting an outcome (list view only)
archived_atdatetime or nullSet once archived; null for live campaigns
logsarrayStructured 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.

On this page