> ## Documentation Index
> Fetch the complete documentation index at: https://docs.hubtalk.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Managing a campaign

> Launch, pause, cancel, progress, statistics and adding batches of numbers.

# Managing a campaign

| Method and path                       | What it does                                                                                                                                                                                                                                                                      |
| ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /v1/campaigns`                   | list, newest first: `limit` (1–200, default 50) and `cursor` → `{campaigns, has_more, next_cursor}`. Items are short views: `campaign_id, status, name, agent_id, created_via, launched_via, starts_at, ends_at, timezone, timezone_source, accepts_call_additions` — no blockers |
| `GET /v1/campaigns/{id}`              | the campaign object (`import: null`) with live/frozen `dnc_screen` and the read-path `blockers`/`warnings`                                                                                                                                                                        |
| `GET /v1/campaigns/{id}/launch-check` | `{blockers, warnings, dnc_screen}` — `dnc_screen` here is always the **live** screen                                                                                                                                                                                              |
| `POST /v1/campaigns/{id}/launch`      | `{"attestation": {"confirmed": true, "import_id": "imp-…"}, "dnc": "block"}`. Success `200` with the campaign object (`blockers: []`); refusal `409 launch_blocked`                                                                                                               |
| `POST /v1/campaigns/{id}/calls`       | add a batch of numbers, see below                                                                                                                                                                                                                                                 |
| `POST /v1/campaigns/{id}/pause`       | `running` → `paused`. Dialed calls play out; no new dials                                                                                                                                                                                                                         |
| `POST /v1/campaigns/{id}/resume`      | `paused` → `running`. Re-runs the launch checks first — anything may have changed while paused                                                                                                                                                                                    |
| `POST /v1/campaigns/{id}/cancel`      | any non-final status → `cancelled`. Irreversible; calls already dialed play out                                                                                                                                                                                                   |
| `GET /v1/campaigns/{id}/progress`     | contact states, holds, pace, do-not-call and frequency counters                                                                                                                                                                                                                   |

**Statuses.** `draft` → (launch) → `scheduled` (when `starts_at` is in the future) or `running`; `scheduled` becomes `running` when the window opens; `running` ⇄ `paused`; `running` → `completed` when the queue drains (nothing pending and nothing in progress) — **except** for a campaign with `accepts_call_additions: true`, which completes only when `ends_at` passes; any non-final status → `cancelled`. `completed` and `cancelled` are final. Reaching `ends_at` closes every campaign.

On `resume` the blockers `no_pending_contacts`, `attestation_required`, `attestation_stale`, `contacts_geo_rejected` and `dnc_screen_required` are not applied.

The `pause` / `resume` / `cancel` responses carry the campaign object **without** `blockers` and `warnings`: those paths do not run the launch checks, and an empty array would read as "checked and clean". Use `launch-check` when you need them.

**Repeating a transition is not an error.** A campaign already in the target state answers `200` with its current object — a retry after a network timeout is not a second intention. A transition impossible *from* the current state (resuming a cancelled campaign, pausing a draft) is `409 campaign_transition_invalid`. The difference between "already done" and "cannot be done" is the one you need.

**A blocked launch or resume is an error, not a `200`.** Creation answers `201` because a campaign and a report really were created; a `launch` — or a `resume`, which re-runs the same checks — that starts nothing answers `409 launch_blocked`, and the reasons travel in the same response:

```json theme={null}
{
  "error": {
    "type": "conflict_error",
    "code": "launch_blocked",
    "status": 409,
    "message": "The campaign did not start: error.blockers names every check that did not pass",
    "blockers": [
      {"code": "agent_not_published", "field": "agent_id"},
      {"code": "dnc_matches", "field": "contacts", "dnc_screen": {"status": "matches", "…": "…"}}
    ]
  }
}
```

The rule across the whole campaign surface: **an action that changed nothing is always an error; a `2xx` means the state in the body is the new state.**

### Adding numbers to an existing campaign

```
POST /v1/campaigns/{id}/calls
Idempotency-Key: <unique string>        (required)

{
  "calls": [
    {"phone": "+77001234567", "timezone": "Asia/Almaty", "variables": {"debt": "15000"}},
    {"phone": "+77007654321", "name": "Daniyar"}
  ],
  "attestation": {"confirmed": true}
}
```

The campaign owns the agent, the dialing binding, the retry rules and the working hours; the batch carries **numbers, variables and a signature** only. Each element has the shape of one `planned_calls` row ([Campaign fields](/v4/web-api/campaign-fields)) — its keys are column names. Unknown top-level keys are ignored: an `agent_id` in the body does not change the campaign's agent, and a `dnc` key does not bypass the registry.

A batch is accepted into any campaign that is not `completed` or `cancelled` — that is, into `draft`, `scheduled`, `running` and `paused`, whether or not it is open to call additions. A closed one answers `409 campaign_closed`.

**Order of refusals**, all before any row is parsed: `422 idempotency_key_required` → `422 calls_required` (empty `calls`) → `422 calls_batch_too_large` → `422 attestation_invalid` → `404 campaign_not_found` → `409 campaign_closed` → idempotency (`422 idempotency_key_reuse` / `409 idempotency_key_in_flight` / replay). Then the rows are parsed: `409 market_package_not_installed`, `422 contact_rows_rejected`, `502 contact_file_storage_unavailable` as on creation, and the two batch refusals below.

<Warning>
  **The batch is accepted whole or refused whole.** One row with a data error refuses the entire batch with `422 calls_rejected`; one number on the do-not-call registry refuses it with `422 calls_dnc_matched` — a distinct code, so your system can tell "fix the rows" from "remove these people from the list". When a batch has both kinds of problem, `calls_rejected` wins. Structural refusals of the parser (no phone column, no usable rows, …) are `calls_rejected` too. No contacts are created for a refused batch, and no signature is recorded.
</Warning>

Both refusals carry `error.import`:

```json theme={null}
{
  "error": {
    "type": "invalid_request_error",
    "code": "calls_rejected",
    "status": 422,
    "message": "…",
    "import": {
      "import_id": "imp-b87d984fe7",
      "status": "refused",
      "refusal_code": "calls_rejected",
      "rows_seen": 2,
      "accepted": 0,
      "warned": 0,
      "rejected": 1,
      "dnc_matched": 0,
      "issues_total": 1,
      "issues": [{"row_number": 2, "code": "phone_unreadable", "severity": "error"}]
    }
  }
}
```

Here `issues` holds the first 50 issues; the full per-row report is `GET /v1/imports/{import_id}/report` (and `?only=dnc_match` for the registry matches). A refused batch is kept as an import record with `status: "refused"` for exactly that purpose.

Success is `201`:

```json theme={null}
{
  "campaign": {
    "campaign_id": "cam-…", "status": "running", "name": "june collections",
    "agent_id": "collections-agent", "created_via": "api", "launched_via": "api",
    "starts_at": null, "ends_at": null, "timezone": "Asia/Almaty",
    "timezone_source": "request", "accepts_call_additions": true
  },
  "import": { "import_id": "imp-…", "status": "accepted", "rows_seen": 2, "accepted": 2, "warned": 0, "rejected": 0, "duplicates_found": 0, "dnc_matched": 0, "refusal_code": "", "issues": {}, "dnc_screen": {"status": "clean", "as_of_launch": false, "…": "…"}, "dnc_matches": [] },
  "blockers": [{"code": "campaign_paused", "field": "status"}],
  "warnings": [{"code": "starts_immediately", "field": ""}, {"code": "frequency_policy_active", "field": ""}]
}
```

`campaign` is the short view (the same object as a list item). `blockers` means "accepted, but not dialing right now, and why": the same launch checks as `launch-check` minus the ones a fresh batch makes moot (`no_pending_contacts`, `attestation_required`, `dnc_screen_required`, `all_contacts_suppressed`, `dnc_matches`), plus `campaign_paused`. Items on this path carry `code` and `field` only.

Rules worth knowing before you stream numbers from a CRM:

* **Cap** — up to 1000 rows per request by default (a deployment setting): `422 calls_batch_too_large` above it, `422 calls_required` for an empty batch. One request counts once against the per-key rate limit.
* **Signature** — `attestation` must be exactly `{"confirmed": true}`: no other keys, no `import_id` (`422 attestation_invalid` otherwise). Every accepted batch is signed separately and bound to its own upload; the launch signature is not reused for rows that did not exist at launch.
* **Time zone** — in a campaign with `use_contact_timezone: true` under the Kazakhstan market profile, a row without a `timezone` is a data error (`timezone_required`) and refuses the batch: the platform will not fall back to the campaign zone for a row nobody looked at. File uploads and `planned_calls` on creation keep the fallback.
* **Duplicates** — inside a batch `duplicate_action` applies as it does for a file. A number that already has a contact in the campaign — even one already dialed — is **accepted again as a new contact and dialed again**: for an adapter that manages its own redials, a repeat *is* the redial. If your system manages redials, keep the default `retry_config.attempts = 1`; with a larger budget redials become two-level.
* **The batch never becomes the campaign's launch import** — the frozen do-not-call snapshot, the launch signature and the `dnc_screen` of `GET /v1/campaigns/{id}` keep pointing at the import the campaign was launched with. The batch's own screen is in the `201` body and at `GET /v1/imports/{import_id}`.
* **Idempotency** — the key is scoped to the campaign, so the same key and body sent to two campaigns are two batches. A refused batch **frees** the key: fix the rows and resend with the same key (a second refused import record is created — diagnostically useful, no calls involved). A repeat of an accepted batch replays the answer; the same key with a different body is `422 idempotency_key_reuse`.

### Campaigns open to added numbers

An ordinary campaign completes itself as soon as its queue drains — and a completed campaign accepts no calls. To keep a campaign waiting for numbers, create it with `"accepts_call_additions": true`. Such a campaign:

* **requires `ends_at`** (`422 campaign_open_ends_at_required`), no further than the operator's horizon — 30 days by default (`422 campaign_open_ends_at_too_far`);
* may be created with no `planned_calls` at all and starts running empty (no `no_pending_contacts` blocker);
* is closed only by `ends_at` arriving or by `cancel`, never by a drained queue.

The flag is set at creation and cannot be changed later; it is returned in the campaign object and in the list.

### Progress and contact states

`GET /v1/campaigns/{id}/progress`:

```json theme={null}
{
  "status": "running",
  "total": 120,
  "by_state": {"pending": 80, "in_progress": 3, "done": 30, "exhausted": 5, "suppressed": 2},
  "ready": 78,
  "holds": 2,
  "held_reasons": {"quiet_hours": 2},
  "suppressed_reasons": {"dnc_suppressed": 2},
  "dnc_suppressed_at_launch": 1,
  "dnc_suppressed_mid_campaign": 1,
  "backpressure": 0,
  "infra_retries": 0,
  "next_attempt_at": 1788942000.0,
  "frequency": {"held": 0, "blocked": 0, "by_governing_cap": {}},
  "rate": {"target_per_minute": 10, "actual_per_minute": 4}
}
```

`by_state` uses the platform's own state names — there is no translation layer, because the difference between "reached" and "out of attempts" matters to you. **States with a zero count are omitted.**

| State         | Meaning                                                                   |
| ------------- | ------------------------------------------------------------------------- |
| `pending`     | waiting to be dialed                                                      |
| `in_progress` | being dialed right now                                                    |
| `awaiting`    | dialed; waiting for the conversation analysis verdict (`expert` strategy) |
| `done`        | reached — no further attempts                                             |
| `exhausted`   | every attempt used, never reached                                         |
| `suppressed`  | excluded before dialing (do-not-call, geography, policy)                  |

`ready` is the number of pending contacts dialable right now; `holds` counts pending contacts held back (reasons in `held_reasons`); `suppressed_reasons` breaks down the `suppressed` state; `dnc_suppressed_at_launch` + `dnc_suppressed_mid_campaign` = `suppressed_reasons.dnc_suppressed`; `frequency` reports contacts currently held or blocked by the per-number frequency caps and the caps governing them. Reason keys inside `held_reasons`, `suppressed_reasons` and `by_governing_cap` are machine codes and may grow.

<Note>
  An ordinary campaign closes itself when its queue drains — a campaign created with `accepts_call_additions: true` does not (only `ends_at` or `cancel` closes that one). There is no "campaign finished" webhook either way — poll `progress` or `GET /v1/campaigns/{id}`.
</Note>

### Withdrawing a contact from dialing

Stop dialing a number before the call happens: the debt was paid, the number belongs to someone else, the case was settled in another channel.

| Method and path                             | Body                          | Scope                                                                             |
| ------------------------------------------- | ----------------------------- | --------------------------------------------------------------------------------- |
| `POST /v1/campaigns/{id}/contacts/withdraw` | `phones` **or** `contact_ids` | that campaign, in any status but `completed` and `cancelled` (a draft is allowed) |
| `POST /v1/contacts/withdraw`                | `phones`                      | every **live** campaign of your organization — `scheduled`, `running`, `paused`   |

```bash theme={null}
curl -s -X POST "$BASE_URL/v1/contacts/withdraw" \
  -H "Authorization: Bearer $CFLOW_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"phones": ["+7 701 123 45 67", "77015550011"]}'
```

`200 OK` lists the contacts by outcome; all five keys are always present:

```json theme={null}
{
  "operation_id": "wd-3f9c2a71b0",
  "withdrawn":         [{"contact_id": "cc-…", "campaign_id": "cam-…", "phone": "7011234567"}],
  "already_withdrawn": [],
  "not_withdrawable":  [{"contact_id": "cc-…", "campaign_id": "cam-…", "phone": "7015550011", "reason": "in_call"}],
  "not_found": [],
  "invalid":   []
}
```

* **What is withdrawn.** A contact waiting to be dialed (`pending`, a scheduled redial included) or waiting for the analytics verdict (`awaiting`). It moves to `suppressed` with the reason `withdrawn_by_client` and is never dialed again in that campaign. For an `awaiting` contact the call **has already happened**: the withdrawal cancels the redial the verdict might have scheduled.
* **What is not.** `not_withdrawable` names the reason: `in_call` — the call is in progress or the contact was just taken for dialing (the withdrawal does not hang up; use `POST /v1/calls/{call_id}/end`, and withdraw again after the call if a redial must not happen), `done`, `exhausted`, or `already_suppressed` — excluded earlier by the platform itself (do-not-call, a frequency cap, geography); that earlier reason is kept.
* **Repeating is safe.** No `Idempotency-Key` is needed: a contact you withdrew before comes back in `already_withdrawn`.
* **Addressing.** A number matches by its **last ten digits**, so any spelling works, and a number may be sent as a string or an integer. One number reaches every contact that carries it — in a campaign with duplicates, all of them. A contact id is the `metadata.contact_id` of the call webhooks; a contact that was never dialed is addressed by its number.
* **The organization endpoint reaches dashboard campaigns too**: every live campaign of your organization, however it was created. Drafts and closed campaigns are not touched.
* **Partial processing.** One bad address does not fail the request: `invalid` returns what could not be read (not a number, no digits, an empty id), `not_found` what found no contact — both exactly as you sent them. At most 1000 addresses per request by default.
* **Consequences.** Withdrawing the last waiting contacts of a running campaign completes it on the next dialer pass, unless the campaign is open to added numbers. A withdrawn number returns to dialing only with a new batch of calls.

<Note>
  There is no withdrawal screen in the dashboard yet: the `withdrawn_by_client` reason shows up in the campaign progress breakdown, but the operation itself is available only through the API.
</Note>
