VoxBridge owns campaign durable state — definition, contact list, lifecycle status, and per-attempt outcomes. VoxDialler reads this state to pace dials; VoxBridge reconciles each call result back into stats. Routes are in routes/campaigns.py (admin JWT), with logic split across campaign_service, campaign_csv_service, campaign_result_service, and campaign_report_service.

Endpoints

All endpoints are under /api/v1/campaigns and require admin JWT (require_admin).
Method + pathAction
POST /Create campaign (status draft). Derives config_url from the request if not supplied.
GET /List campaigns (optional ?status=).
GET /{id}Get one campaign.
PATCH /{id}Update (409 if not in an editable state).
DELETE /{id}Delete (409 unless draft).
POST /{id}/uploadUpload contacts CSV (draft only, identity not locked).
POST /{id}/startStart (draft/pausedrunning).
POST /{id}/pausePause (runningpaused).
POST /{id}/resumeResume (alias of start).
POST /{id}/stopStop (running/pausedcompleted).
POST /{id}/restartPrepare a re-run (→ draft).
POST /{id}/cancel-restartUndo a prepared restart.
POST /{id}/cloneClone a campaign.
GET /{id}/callsList per-contact records (filters: status, date, phone, callback).
GET /{id}/attempt-report.csvDownload the per-attempt CSV report.
Lifecycle actions append an entry to the campaign activity_log (action, by, at).

State machine

start_campaign requires pending calls (pending / retry_scheduled / callback_scheduled) and passes _validate_campaign_can_start (bot exists, non-empty number pool, concurrency_limit > 0, time-window start/end set). stop_campaign drains gracefully: it cancels waiting work (and scheduled callbacks → callback.status: cancelled) to manual_stopped, but lets active in-progress calls drain and return real outcomes. It reconciles stats (pending down, manual_stopped up) and sets completion_reason: manual_stop.
A campaign’s contact identity locks once a run is prepared (campaign_identity_locked: started_at, completed_at, any restart_history, or run_number > 1). After that, CSV re-upload is rejected with 409.

Config model

CampaignConfig (models/campaign.py):
FieldDefaultMeaning
concurrency_limit50Max simultaneous connected conversations.
campaign_modesingle_daysingle_day or multi_day.
time_window09:00–18:00 UTCDialling window + timezone.
redial_rulesmax_attempts (3), retry_delay_minutes (30), retry_on_system, retry_on_custom.
callback_detectiondisabledGate for callback scheduling. See Callbacks.

CSV ingest

process_csv_upload (campaign_csv_service.py):
  • Requires a phone_number column (utf-8-sig decoded). Limits: 50 MB, 500k rows.
  • Validates and deduplicates phone numbers within the file; invalid/duplicate rows are counted as skipped.
  • All other columns become per-contact variables.
  • Validation happens before any destructive write; only after confirming valid rows does it delete_many existing campaign_calls and insert_many the new set. Stats total_calls / pending are reset to the uploaded count.
  • Returns { uploaded, skipped, total_rows } (plus truncated/max_rows if capped).

Result reconciliation

process_campaign_call_result (campaign_result_service.py) is invoked from the results webhook for campaign-linked calls (_campaign_call_id in the connected event):
  • Atomic idempotency: flips the contact to _processing only from dialling/in_progress/attaching, so concurrent duplicate webhooks cannot double-count.
  • Late results from a superseded attempt update that attempt’s audit record only, without touching status/stats.
  • Updates the matching embedded attempt and mirrors it into the first-class campaign_call_attempts audit collection (_upsert_attempt_result).
  • Decides retry vs terminal from redial_rules (retry_on_system + retry_on_custom, case-insensitive disposition match) and max_attempts.
  • Callback scheduling takes priority over normal retry when enabled and requested.

Live activity and stats

_compute_live_activity returns a real-time view for the dashboard: ringing, in_progress, pending, retry_scheduled, calls_per_minute (2-minute window), dials_per_second, connected_per_hour, eta_minutes, last_dial_at, and a per-pass breakdown (attempted / eligible / connected / timing). recompute_campaign_stats and compute_campaign_run_stats recompute durable stats (STATS_SCHEMA_VERSION = 2).

Reporting

build_campaign_attempt_report_csv (campaign_report_service.py) emits one row per attempt. It joins campaign_call_attempts with call docs, dynamically adds var_* (contact variables) and analysis_* (flattened post-call analysis) columns on top of BASE_HEADERS, formats timestamps in the campaign timezone, and resolves a stable public recording URL per row (capped at 500k rows).

Operational checks

SymptomFirst place to check
Start rejected 409No pending calls, or _validate_campaign_can_start failure (bot/pool/concurrency/window).
CSV upload 409Campaign not draft, or identity locked after a run.
Stats double-countedShould be prevented by the _processing atomic flip; check duplicate webhooks.
Retries never fireredial_rules.retry_on_system/retry_on_custom vs disconnected_by/disposition.
Report missing columnsVariables/analysis keys only appear if present on some attempt/call.

Campaign callbacks

Callback extraction and double-gated scheduling.

VoxDialler overview

The pacing engine that consumes campaign state.

Create a campaign

Operator-facing campaign setup.

Pacing and retries

Operator-facing pacing/retry behaviour.