Skip to content

Campaigns ​

Outbound calling at volume: a contact list, an assistant, a schedule, and the compliance controls that make outbound calling legal. A campaign is a dispatcher that decides who to call next; each attempt becomes an ordinary call with the campaign id on it, so campaign analytics are ordinary call analytics with a filter.

The model ​

Campaign  →  CampaignContact  →  CampaignAttempt  →  Call

The campaign holds the target and the rules. Each contact carries its own phone number, timezone, variables, consent record and state. Each attempt records the try — when it was scheduled, when it was dispatched, what the outcome was, and what it cost.

API ​

GET    /api/v1/campaigns
POST   /api/v1/campaigns
GET    /api/v1/campaigns/{campaign}
PATCH  /api/v1/campaigns/{campaign}
DELETE /api/v1/campaigns/{campaign}

POST   /api/v1/campaigns/{campaign}/start
POST   /api/v1/campaigns/{campaign}/pause
POST   /api/v1/campaigns/{campaign}/resume
POST   /api/v1/campaigns/{campaign}/cancel

GET    /api/v1/campaigns/{campaign}/progress
GET    /api/v1/campaigns/{campaign}/results
GET    /api/v1/campaigns/{campaign}/contacts
POST   /api/v1/campaigns/{campaign}/contacts
POST   /api/v1/campaigns/{campaign}/contacts/upload
POST   /api/v1/campaigns/preview-contacts

Full parameters and responses: campaign reference.

bash
curl -X POST "$CALLAGENT_API_URL/campaigns" \
  -H "Authorization: Bearer $CALLAGENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "January renewals",
    "target_type": "assistant",
    "assistant_id": "01JQ…",
    "origination_type": "phone_number",
    "phone_number_id": "01JQ…",
    "timezone": "Europe/Bucharest",
    "max_concurrency": 5
  }'
ts
const campaign = await fetch(`${process.env.CALLAGENT_API_URL}/campaigns`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.CALLAGENT_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    name: 'January renewals',
    target_type: 'assistant',
    assistant_id: assistantId,
    origination_type: 'phone_number',
    phone_number_id: numberId,
    timezone: 'Europe/Bucharest',
    max_concurrency: 5,
  }),
}).then((response) => response.json());

await fetch(`${process.env.CALLAGENT_API_URL}/campaigns/${campaign.data.id}/start`, {
  method: 'POST',
  headers: { Authorization: `Bearer ${process.env.CALLAGENT_API_KEY}` },
});
python
import os, requests

auth = {"Authorization": f"Bearer {os.environ['CALLAGENT_API_KEY']}"}
base = os.environ["CALLAGENT_API_URL"]

campaign = requests.post(f"{base}/campaigns", headers=auth, timeout=30, json={
    "name": "January renewals",
    "target_type": "assistant",
    "assistant_id": assistant_id,
    "origination_type": "phone_number",
    "phone_number_id": number_id,
    "timezone": "Europe/Bucharest",
    "max_concurrency": 5,
}).json()["data"]

requests.post(f"{base}/campaigns/{campaign['id']}/start", headers=auth, timeout=30)

Configuration ​

GroupFields
Targettarget_type, assistant_id (and version), squad_id
Originationorigination_type, phone_number_id or sip_trunk_id, caller id
Windowtimezone, window_start_time, window_end_time, calling_days, starts_at, ends_at
Pacingmax_concurrency, dispatch_batch_size, max_dials_per_minute
Retryretry_policy: max_attempts, base_delay_minutes, backoff
Voicemaildetect_voicemail, voicemail_behavior, voicemail_message
Screeningscreening_mode, answer_timeout_seconds, max_call_seconds
Compliancea single compliance object — see the warning below
Suppressionsuppression_list_ids, skip_suppressed
Variablesvariables — merged into each call's variableValues

Compliance settings must be nested

allowed_countries, blocked_countries, max_attempts and require_consent live inside a compliance object:

json
{ "compliance": {
    "allowed_countries": ["RO", "DE"],
    "require_consent": true,
    "max_attempts": 3
} }

Sent flat at the top level they are silently dropped — the create request validates only a generic compliance array, so there is no error to tell you they were ignored.

Contacts ​

Add them as JSON, or upload a CSV and preview the mapping before committing to it:

bash
curl -X POST "$CALLAGENT_API_URL/campaigns/01JQ…/contacts" \
  -H "Authorization: Bearer $CALLAGENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"contacts": [
        {"phone": "+40712345678", "name": "Ana", "variables": {"plan": "pro"}}
      ]}'

The field is phone, not phone_number

The validated field on a contact is contacts.*.phone, and the CSV importer reads phone too. Posting phone_number fails validation with "the phone field is required".

A contact carries external_id, phone, country, timezone, name, email, variables, a consent record, an optional customer_id, and its own state with a reason — so "why was this person not called" has an answer per row rather than per campaign.

Compliance ​

These are not extras. They are what makes outbound calling lawful in most jurisdictions.

ControlEffect
Calling windowEvaluated in the destination's local time, not the organization's.
Suppression listsChecked per attempt. Managed separately at /api/v1/suppression-lists.
Attempt capsPer contact, per day and in total.
Consent recordStored against the contact and echoed on the call.
Country policyallowed_countries / blocked_countries.
AbandonmentPacing is capped so the abandonment rate stays under the configured limit.

Suppression lists are first-class resources with their own entries endpoints — see the suppression reference — so a do-not-call list can be shared by every campaign and updated without touching any of them.

The dispatcher ​

The dispatcher decides who to call next given pacing, concurrency headroom and the calling window. It never exceeds the organization's reserved concurrency, so a campaign cannot starve inbound calls, and the slot is released on finalisation even if the call crashes.

Campaign dials do not go through the billing admission check

The dispatcher is built to take a credit gate so a campaign's dials pass the same wallet check as any other outbound call. Nothing binds that gate. Only the concurrency gate is registered, so every campaign dispatch falls back to a null credit gate that reads a raw credit_balance attribute if there is one and otherwise admits.

Concurrency, the calling window, suppression and the destination policy all apply. The full billing admission — daily spend cap, plan volume ceilings, dunning freeze, ledger balance — does not. Cap a campaign with max_dials_per_minute and its own attempt limits rather than relying on the wallet to stop it.

Watching it run ​

GET /campaigns/{campaign}/progress returns the counters the campaign maintains — contacts total, pending, completed, failed, skipped, and attempts total. GET /campaigns/{campaign}/results returns the outcomes. The dashboard's campaign page shows both plus the attempt-by-attempt history.

Three campaign events are delivered as webhooks: campaign.started, campaign.contact_attempted and campaign.completed. Those three are the whole vocabulary — there is no campaign.scheduled, campaign.dispatched, campaign.paused or campaign.failed, and no code that would emit one.

On a call itself, campaign.opt_out_detected is a call event, not a webhook.

Next ​

Documented against the code. Anything not yet implemented is labelled, never described as working.