---
title: "Campaigns"
description: "Run outbound calling campaigns: upload contacts, set calling windows, and track outcomes."
---

A **campaign** dials a list of contacts with one of your agents, from
one of your outbound-capable numbers, inside a daily calling window in
the timezone you pick. The runner respects a per-campaign concurrency
cap and your organization's [call limits](/api-reference/introduction#call-limits)
(a contact that meets a limit waits for a later pass without using an
attempt), and retries contacts whose calls ended in outcomes you selected
(`no_answer`, `voicemail`, `failed`, …) with a configurable backoff,
up to `max_attempts` per contact. See
[calling hours and retry limits](#calling-hours-and-retry-limits) for
the timezone default, the window warnings, and the retry bounds.

Lifecycle: `draft` → (`scheduled` →) `running` → `completed`, with
`paused` and `cancelled` reachable via [actions](#campaign-actions).
Contacts are only editable while the campaign is a `draft`.

## Endpoints

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/v1/campaigns` | List campaigns |
| `POST` | `/v1/campaigns` | Create a campaign (starts in `draft`) |
| `GET` | `/v1/campaigns/{campaign_id}` | Retrieve a campaign incl. contacts |
| `PATCH` | `/v1/campaigns/{campaign_id}` | Update a `draft`/`scheduled`/`paused` campaign |
| `DELETE` | `/v1/campaigns/{campaign_id}` | Delete a campaign |
| `POST` | `/v1/campaigns/{campaign_id}/contacts` | Add contacts (CSV upload or JSON) |
| `GET` | `/v1/campaigns/{campaign_id}/stats` | Progress counters + recent calls |
| `POST` | `/v1/campaigns/{campaign_id}/{action}` | `start`, `pause`, or `cancel` |

`{campaign_id}` is the campaign's **UUID** (`public_id`).

## Campaign object

```json
{
  "public_id": "3f6b2c9e-…",
  "name": "May win-back",
  "agent": 12,
  "agent_name": "Customer Support Agent",
  "from_number": "+15551234567",
  "status": "draft",
  "timezone": "America/Los_Angeles",
  "daily_window_start": "09:00:00",
  "daily_window_end": "17:00:00",
  "start_at": null,
  "end_at": null,
  "max_concurrent_calls": 3,
  "max_attempts": 2,
  "retry_backoff_minutes": 60,
  "retry_on": ["no_answer", "voicemail", "failed"],
  "total_count": 0,
  "completed_count": 0,
  "failed_count": 0,
  "in_progress_count": 0,
  "warnings": [],
  "created_at": "2026-04-20T18:24:10.113Z",
  "updated_at": "2026-04-20T18:24:10.113Z"
}
```

| Field | Type | Description |
|-------|------|-------------|
| `public_id` | UUID | Campaign id (used in every path) |
| `name` | string | 1–255 chars |
| `agent` | integer | The [Agent](/api-reference/agents) that speaks on every call (writable) |
| `agent_name` | string | Read-only convenience |
| `from_number` | string | An outbound-capable number in your org. ThunderPhone numbers are accepted after outbound calling is approved for your organization |
| `status` | string | `draft`, `scheduled`, `running`, `paused`, `completed`, `cancelled` (read-only — change via actions) |
| `timezone` | string | IANA timezone the daily window is evaluated in. When omitted on create, it defaults to the `from_number`'s zone (its area code in the US and Canada, its country elsewhere), else `UTC` |
| `daily_window_start`, `daily_window_end` | time | Local calling window, e.g. `"09:00:00"`–`"17:00:00"`. Calls start from `daily_window_start` up to, not at, `daily_window_end`. On create, send both or neither; neither means `"09:00:00"`–`"17:00:00"`. An end earlier than the start makes an overnight window (`"22:00:00"`–`"02:00:00"` dials from 10pm until 2am the next day) |
| `start_at`, `end_at` | timestamp \| null | Optional overall schedule bounds. `end_at` must be after `start_at` |
| `max_concurrent_calls` | integer | 1–10, default 3 |
| `max_attempts` | integer | 1–10, default 2 — total tries per contact. The upper bound is per organization and staff can raise it |
| `retry_backoff_minutes` | integer | Delay before a retryable contact is attempted again. At least 30, default 60. Staff can lower the minimum per organization |
| `retry_on` | array of string | Call outcomes that trigger a retry. Default `["no_answer", "voicemail", "failed"]` |
| `total_count`, `completed_count`, `failed_count`, `in_progress_count` | integer | Progress counters (read-only) |
| `warnings` | array of object | Saved settings worth a second look, each `{code, field, detail}` (read-only). `outside_calling_hours` for a window that starts before 08:00 (`field` is `daily_window_start`) or ends after 21:00 or crosses midnight (`daily_window_end`), one entry per edge. `window_crosses_midnight` for an overnight window and `empty_window` for equal start and end, which does not dial. Empty when there is nothing to flag |

---

## Create a campaign

<Note>
  For organizations created on or after 2026-08-03, campaign creation
  and the `start` action require the organization's one-time
  [outbound TCPA
  confirmation](/api-reference/outbound-calls#outbound-tcpa-confirmation).
  Without it they return `403` with
  `"code": "outbound_tcpa_confirmation_required"`. Older organizations
  are fully exempt. `pause`/`cancel` and all reads are unaffected, and
  already-running campaigns are never interrupted.
</Note>

<CodeGroup>
```bash cURL
curl -X POST https://api.thunderphone.com/v1/campaigns \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "May win-back",
    "agent": 12,
    "from_number": "+15551234567",
    "timezone": "America/Los_Angeles",
    "daily_window_start": "09:00",
    "daily_window_end": "17:00",
    "max_concurrent_calls": 3,
    "max_attempts": 2,
    "retry_on": ["no_answer", "voicemail"]
  }'
```
</CodeGroup>

Required: `name`, `agent`, `from_number`, `daily_window_start`,
`daily_window_end`. Returns `201 Created` with the campaign in
`status="draft"`. Validation errors (`400`) cover unknown agents,
numbers not registered to your org, ThunderPhone numbers when outbound calling
has not been approved for your organization, invalid IANA timezones,
`end_at <= start_at`, and retry settings outside your organization's
bounds (below). Daily windows are never refused; unusual ones come back
with `warnings`.

### Calling hours and retry limits

- **Timezone default.** Omit `timezone` and the campaign uses the
  `from_number`'s zone: its area code for US and Canadian numbers
  (`+1 415…` is `America/Los_Angeles`), its country elsewhere. Numbers
  that span several zones, such as toll-free numbers, fall back to `UTC`.
  The same value is on the [phone number object](/api-reference/phone-numbers#phone-number-object)
  as `timezone`. A `timezone` you send always wins.
- **Calling-hours warning.** Calls outside 8am–9pm local time may break
  calling-hours rules such as the TCPA's. A window that starts before
  `08:00` or ends after `21:00` is saved, and the response lists an
  `outside_calling_hours` entry in `warnings`. The window runs in the
  campaign's timezone, not each contact's: a 9am start in New York
  reaches a Los Angeles contact at 6am.
- **Overnight windows.** A `daily_window_end` earlier than
  `daily_window_start` crosses midnight: `22:00`–`02:00` dials from 10pm
  until 2am the next day. It is saved with `window_crosses_midnight` and
  `outside_calling_hours` warnings, for example when your contacts sit in
  a timezone hours away from the campaign's. Equal start and end
  (`09:00`–`09:00`) is saved with an `empty_window` warning and never
  dials.
- **Retry bounds.** `max_attempts` is at most **10** and
  `retry_backoff_minutes` at least **30**. Values outside them return
  `400` on that field. Staff can raise both for your organization;
  contact support.

```json 400 Bad Request
{
  "max_attempts": ["Campaigns can make at most 10 attempts per contact. Contact support to raise this limit."]
}
```

## Retrieve / update / delete

`GET /v1/campaigns/{id}` returns the campaign **plus** a `contacts`
array (first 500 [contact objects](#contact-object); filter with
`?contact_status=pending|scheduled|calling|completed|failed|exhausted`).

`PATCH` accepts any subset of the writable fields, but only while
status is `draft`, `scheduled`, or `paused` — otherwise
`409 Conflict`.

Campaigns saved before the retry bounds keep running exactly as
configured. Sending a `max_attempts` or `retry_backoff_minutes` outside
the bounds is refused only when it changes the saved value, so other
edits still succeed.

`DELETE` returns `204 No Content` and removes the campaign and its
contacts (already-made call logs are kept).

---

## Add contacts

Only allowed while the campaign is a **draft** (`409` otherwise).
Two formats:

<CodeGroup>
```bash CSV upload
curl -X POST https://api.thunderphone.com/v1/campaigns/3f6b2c9e-…/contacts \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -F 'file=@contacts.csv'
```

```bash JSON
curl -X POST https://api.thunderphone.com/v1/campaigns/3f6b2c9e-…/contacts \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "contacts": [
      { "phone_number": "+14155552671", "first_name": "Jamie", "account_id": "A-1042" }
    ]
  }'
```
</CodeGroup>

- **CSV:** UTF-8, must have a `phone` or `phone_number` column. Every
  **other** column becomes a per-contact variable.
- **JSON:** a top-level list, or `{"contacts": [...]}` — each object
  needs `phone_number` (or `phone`); all other keys become variables.
- Max **5,000 rows** per request. Numbers are normalized to E.164
  (8–15 digits); invalid rows are rejected individually, not the whole
  batch.
- Rows with a number ThunderPhone does not call are rejected the same way,
  under the [outbound call rules](/api-reference/outbound-calls#blocked-destinations):
  emergency numbers, short codes, sanctioned countries, and, for a
  ThunderPhone caller ID, premium-rate numbers and anything but ordinary US
  and Canadian numbers. The row's `reason` field names the rule. A contact that
  reaches dial time with such a number fails with `last_outcome`
  `destination_not_allowed` and is not retried.

```json Response (201 Created)
{
  "accepted": 148,
  "rejected": 2,
  "errors": [
    { "row": 17, "value": "555-0100", "error": "\"555-0100\" isn't a valid phone number. Check the digits and the country code.", "reason": "invalid_number" },
    { "row": 42, "value": "", "error": "Expected an object." }
  ],
  "errors_truncated": false
}
```

`errors` carries at most 100 entries; `errors_truncated` is `true` when more
rows were rejected than listed. `value` is the phone value of the rejected row
(empty when the row was not an object).

### Contact object

`{id, phone_number, variables, status, attempts, next_attempt_at, last_outcome, call_id, dial_sip_status, dial_sip_reason, dial_sip_kind, created_at, updated_at}`
— `status` is `pending`, `scheduled`, `calling`, `completed`,
`failed`, or `exhausted` (retries used up); `call_id` links the most
recent [call](/api-reference/calls). `dial_sip_status`, `dial_sip_reason`
and `dial_sip_kind` are that call's `outbound_dial_sip_status`,
`outbound_dial_sip_reason` and `outbound_dial_sip_kind` (e.g. `403`,
`Account is disabled D17` and `refused` when your carrier refused the
dial); all are `null` when there is no call or no carrier answered it.

---

## Campaign actions

`POST /v1/campaigns/{id}/{action}` where `action` is:

| Action | Body | Effect |
|--------|------|--------|
| `start` | `{"consent_to_charge": true}` (required) | `draft`/`paused`/`scheduled`/`cancelled` → `running` (or `scheduled` when `start_at` is in the future). Requires at least one contact and the org's [outbound TCPA confirmation](/api-reference/outbound-calls#outbound-tcpa-confirmation) |
| `pause` | — | `running`/`scheduled` → `paused` |
| `cancel` | — | Any non-terminal status → `cancelled` |

Returns `200 OK` with the updated campaign. Invalid transitions return
`409`; unknown actions return `404`.

---

## Stats

<CodeGroup>
```bash cURL
curl https://api.thunderphone.com/v1/campaigns/3f6b2c9e-…/stats \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
</CodeGroup>

```json Response
{
  "total": 150,
  "completed": 41,
  "failed": 3,
  "in_progress": 2,
  "by_status": { "pending": 96, "scheduled": 8, "calling": 2, "completed": 41, "failed": 3 },
  "recent_calls": [
    {
      "contact_id": 9911,
      "phone_number": "+14155552671",
      "call_id": 987654321,
      "status": "completed",
      "outcome": "completed",
      "started_at": "2026-04-20T18:24:10.113Z"
    }
  ]
}
```

`recent_calls` holds the 20 most recently updated contacts that have a
call attached.

---

## Related

<CardGroup cols={2}>
  <Card title="Outbound Calls" icon="arrow-up-right" href="/api-reference/outbound-calls">
    Place a single ad-hoc outbound call instead.
  </Card>
  <Card title="Phone Numbers" icon="phone" href="/api-reference/phone-numbers">
    Campaigns need an outbound-enabled customer VoIP or ThunderPhone number.
  </Card>
  <Card title="Agents" icon="robot" href="/api-reference/agents">
    The agent that speaks on campaign calls.
  </Card>
  <Card title="Calls" icon="phone-volume" href="/api-reference/calls">
    Every attempt appears in call history.
  </Card>
</CardGroup>
