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 → CallThe 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-contactsFull parameters and responses: campaign reference.
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
}'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}` },
});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
| Group | Fields |
|---|---|
| Target | target_type, assistant_id (and version), squad_id |
| Origination | origination_type, phone_number_id or sip_trunk_id, caller id |
| Window | timezone, window_start_time, window_end_time, calling_days, starts_at, ends_at |
| Pacing | max_concurrency, dispatch_batch_size, max_dials_per_minute |
| Retry | retry_policy: max_attempts, base_delay_minutes, backoff |
| Voicemail | detect_voicemail, voicemail_behavior, voicemail_message |
| Screening | screening_mode, answer_timeout_seconds, max_call_seconds |
| Compliance | a single compliance object — see the warning below |
| Suppression | suppression_list_ids, skip_suppressed |
| Variables | variables — 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:
{ "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:
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.
| Control | Effect |
|---|---|
| Calling window | Evaluated in the destination's local time, not the organization's. |
| Suppression lists | Checked per attempt. Managed separately at /api/v1/suppression-lists. |
| Attempt caps | Per contact, per day and in total. |
| Consent record | Stored against the contact and echoed on the call. |
| Country policy | allowed_countries / blocked_countries. |
| Abandonment | Pacing 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
- Telephony — the numbers a campaign dials from.
- Analytics & cost — campaign results are call analytics.