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 + path | Action |
|---|---|
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}/upload | Upload contacts CSV (draft only, identity not locked). |
POST /{id}/start | Start (draft/paused → running). |
POST /{id}/pause | Pause (running → paused). |
POST /{id}/resume | Resume (alias of start). |
POST /{id}/stop | Stop (running/paused → completed). |
POST /{id}/restart | Prepare a re-run (→ draft). |
POST /{id}/cancel-restart | Undo a prepared restart. |
POST /{id}/clone | Clone a campaign. |
GET /{id}/calls | List per-contact records (filters: status, date, phone, callback). |
GET /{id}/attempt-report.csv | Download the per-attempt CSV report. |
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):
| Field | Default | Meaning |
|---|---|---|
concurrency_limit | 50 | Max simultaneous connected conversations. |
campaign_mode | single_day | single_day or multi_day. |
time_window | 09:00–18:00 UTC | Dialling window + timezone. |
redial_rules | — | max_attempts (3), retry_delay_minutes (30), retry_on_system, retry_on_custom. |
callback_detection | disabled | Gate for callback scheduling. See Callbacks. |
CSV ingest
process_csv_upload (campaign_csv_service.py):
- Requires a
phone_numbercolumn (utf-8-sigdecoded). 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_manyexistingcampaign_callsandinsert_manythe new set. Statstotal_calls/pendingare reset to the uploaded count. - Returns
{ uploaded, skipped, total_rows }(plustruncated/max_rowsif 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
_processingonly fromdialling/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_attemptsaudit collection (_upsert_attempt_result). - Decides retry vs terminal from
redial_rules(retry_on_system+retry_on_custom, case-insensitive disposition match) andmax_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
| Symptom | First place to check |
|---|---|
Start rejected 409 | No pending calls, or _validate_campaign_can_start failure (bot/pool/concurrency/window). |
CSV upload 409 | Campaign not draft, or identity locked after a run. |
| Stats double-counted | Should be prevented by the _processing atomic flip; check duplicate webhooks. |
| Retries never fire | redial_rules.retry_on_system/retry_on_custom vs disconnected_by/disposition. |
| Report missing columns | Variables/analysis keys only appear if present on some attempt/call. |
Related docs
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.