Campaigns
A campaign is the run control for an outbound flow. The flow holds the configuration; these three endpoints start it, stop it, and report what it is doing.
PUBLIC_BASE_URL is requiredStarting a campaign fails immediately without it — the provider has to be able to reach your answer and hangup webhooks, and every call would otherwise connect to silence.
{ "detail": "PUBLIC_BASE_URL is not set, so telephony cannot reach this server to run a campaign" }
Start a campaign
POST /api/voice/flows/{uuid}/campaign/start
Marks the flow active and starts the runner in the background.
202 Accepted:
{ "status": "running" }
| Status | Cause |
|---|---|
400 | PUBLIC_BASE_URL is not set |
404 | No outbound flow with this UUID in your organization |
409 | {"error": "this campaign is already running"} |
The 409 is deliberate: two runners over one contact list would call half of it
twice, and the customer has no way to tell it was a bug.
The 202 acknowledges that the flow was marked active and the runner was
launched. Telephony credentials are validated by the runner itself, so check
campaign status afterwards — status: "active" together
with running: true confirms it is dialling. A connected Plivo integration
with a valid E.164 number is required.
Stop a campaign
POST /api/voice/flows/{uuid}/campaign/stop
{ "status": "paused", "was_running": true }
was_running is false when no runner was live in this process — after a
restart, for instance. The flow is marked paused either way, which is what
makes a campaign stoppable after the process that started it has gone.
Cancellation is immediate: a runner waiting for its next dialling slot stops now rather than when the timer next fires.
Campaign status
GET /api/voice/flows/{uuid}/campaign/status
{
"status": "active",
"running": true,
"contact_tags": ["winback", "north"],
"time_window": { "from": "09:00", "to": "18:00" },
"max_retries": 2,
"calls_per_minute": 10
}
| Field | Meaning |
|---|---|
status | The flow's stored status |
running | Whether a runner is alive in this process |
The two can disagree. A process restart loses in-memory runners while the flow
stays active — shown as active + running: false, which is a campaign that
needs restarting rather than one quietly making progress.
How the runner works
For each contact, in id order:
- Stop if the flow is no longer
active— checked every contact, so a pause takes effect within a call or two rather than at the end of the list - Stop if outside the calling window
- Skip if already handled — see the rules below
- Write the call row, then dial
- Wait
60 / calls_per_minuteseconds
The call row is written before the provider is called, so a call that fails at dial time still leaves a record. A campaign with no record of its failures cannot be diagnosed.
Who gets skipped
| Condition | Reason |
|---|---|
| No phone number | Not callable |
| Last outcome is not a no-answer | Already reached — whatever they said |
Attempts exceed max_retries | Enough |
Last call is within retry_interval_minutes | Too soon; same number twice in a minute reads as a robocall |
Only these outcomes are treated as "did not pick up" and therefore retryable:
no_answer, busy, failed, customer_no_answer.
A contact whose last call carries any other outcome — including the
initiated value a campaign call is created with — is treated as already
reached and is not dialled again. The server log names the reason for each
skip:
[campaign 3] skipping Priya Kumar: already reached (initiated)
Call history
The runner loads every prior outcome for the flow before dialling anything. If that query fails it aborts rather than continuing — calling everyone who was already reached is the one mistake a campaign must never make.
Calling windows
{ "from": "09:00", "to": "18:00" }
24-hour local time. A window where from is later than to wraps midnight and
is treated as two ranges. An unparseable window allows calling rather than
blocking it — a campaign silently dialling nobody because of a typo is worse
than one that runs when it should not.
Outside the window the run stops rather than sleeping until the window reopens. Start it again when the window is open.
TZ on the serviceThe window is evaluated against the server's local time; there is no
per-organization timezone. On a UTC server a 09:00–18:00 window means
14:30–23:30 IST. Set TZ on the process to the timezone your windows are
written in.
Run lifecycle
- A start makes one pass. The runner works to the bottom of the contact list and finishes. Contacts skipped because their retry interval had not elapsed are picked up the next time the campaign is started.
start_dateandend_dateare stored on the flow and returned by the API for your own scheduling; starting a campaign dials immediately regardless of them.- Runners live in the process that started them. After a restart, a
campaign shows
status: "active"withrunning: falseand should be started again.