> ## 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.

# Creating a campaign

> `POST /v1/campaigns`: headers, request body, the order of refusals and the successful response.

# Creating a campaign

One request does three things: it creates the campaign, accepts the planned calls and — if every launch check passes — starts dialing. There is no separate "start" step in the happy path.

## Headers

| Header                               | Required | Notes                                                                                                                                                                                                                                                                                          |
| ------------------------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Authorization: Bearer YOUR_API_KEY` | yes      |                                                                                                                                                                                                                                                                                                |
| `Content-Type: application/json`     | yes      |                                                                                                                                                                                                                                                                                                |
| `Idempotency-Key: <unique string>`   | **yes**  | Unlike `POST /v1/calls/*`, it is not optional here: a retry after a network timeout would create a second campaign and dial the whole list twice. Missing or blank → `422 idempotency_key_required`. The protocol is in [Idempotency of campaign requests](#idempotency-of-campaign-requests). |

## Request body

Unknown top-level keys are ignored.

| Field                                 | Type                                                  | Required | Default                              | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| ------------------------------------- | ----------------------------------------------------- | -------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name`                                | string                                                | yes      | —                                    | Campaign name shown in the dashboard; 1–80 characters after trimming (else `422 campaign_spec_invalid`).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `agent_id`                            | string                                                | yes      | —                                    | Agent **name**, resolved inside your organization. Unknown name → `422 campaign_agent_unknown`. An existing but unpublished agent is accepted: the campaign is created as a draft with the `agent_not_published` blocker.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `calls_per_minute`                    | integer                                               | yes      | —                                    | The pace is a **tier**, not an arbitrary number: one of `1, 2, 5, 10, 20, 30, 60, 100, 200` (anything else → `422 campaign_spec_invalid`). The value is applied as sent: there is no deployment ceiling that would cut it.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `planned_calls`                       | array                                                 | no       | `[]`                                 | The numbers to dial, see below. Empty means the campaign is created with nothing to dial (blocker `no_pending_contacts`, unless `accepts_call_additions` is `true`).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `starts_at` / `ends_at`               | number \| null                                        | no       | `null`                               | Campaign window. A `null` start means "now". `ends_at` must be later than `starts_at` (otherwise `422 campaign_window_invalid`). A start **date** in the past (by calendar date in the campaign zone) blocks the launch (`starts_date_past`); a past `ends_at` blocks it too (`ends_at_past`).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `timezone`                            | string                                                | no       | `""`                                 | IANA zone name (`Asia/Almaty`) in which the window and the working hours are interpreted. Omitted → the organization's zone. Unknown zone → `422 campaign_timezone_unknown`; fixed offsets are not accepted.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `working_hours`                       | array                                                 | no       | `[]`                                 | Schedule rows `{"days": [0..6], "start": "HH:MM", "end": "HH:MM"}` (`days`: 0 = Monday, non-empty; `start` strictly before `end`; a row never crosses midnight — a night shift is two rows). Invalid form → `422 campaign_spec_invalid`. A campaign may only **narrow** the floor that applies to your organization: under a US market package the legal calling window of the contacts' jurisdictions, otherwise the organization's calling window; a wider row → `422 working_hours_wider_than_law`. **Empty means the floor itself applies** — the campaign narrows nothing, and dialing is limited by the legal window (warning `working_hours_law_only`) or by the organization's calling window (warning `working_hours_org_window_only`). Dialing is never round-the-clock. |
| `call_strategy`                       | `"standard"` \| `"expert"`                            | no       | `"standard"`                         | `standard` retries on a fixed ladder; `expert` decides per outcome from `retry_config.rules`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `retry_config`                        | object                                                | no       | `{"attempts": 1, "interval_s": 900}` | See [Retry configuration](#retry-configuration). **The default is one attempt — no redials.**                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `duplicate_action`                    | `"reject"` \| `"keep_first"` \| `"keep_last"`         | no       | `"reject"`                           | What to do when the same phone appears twice in `planned_calls` (and inside a `calls` batch).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `classification`                      | `"marketing"` \| `"servicing"` \| `"debt_collection"` | no       | `"marketing"`                        | What the campaign calls about. It decides the **consent tier** the attestation is signed under (`marketing` → `pewc`, `servicing` and `debt_collection` → `pec`), so it is never guessed: an omitted value takes the strictest tier. Anything else → `422 campaign_classification_invalid`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| `use_contact_timezone`                | boolean                                               | no       | `false`                              | Dial each contact in its own zone (from the contact's `timezone` column) instead of the campaign's.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `trunk_id`, `route_id`, `from_number` | string                                                | no       | resolved                             | Dialing binding, see below.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| `attestation`                         | object                                                | no       | —                                    | `{"confirmed": true}` — the consent attestation required before dialing. Optional at creation: without it the campaign is created as a draft with the `attestation_required` blocker.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `accepts_call_additions`              | boolean                                               | no       | `false`                              | Keep the campaign open to [added batches of numbers](#adding-numbers-to-an-existing-campaign): a drained queue does not complete it; only `ends_at` or `cancel` does. Requires `ends_at` (`422 campaign_open_ends_at_required`) no further ahead than the operator's horizon, 30 days by default (`422 campaign_open_ends_at_too_far`). Set at creation only.                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `dnc`                                 | `"block"` \| `"skip"`                                 | no       | `"block"`                            | What to do about numbers matching the do-not-call registry. Anything else → `422 dnc_decision_invalid`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |

Fields that exist on the campaign but are **not** settable through `/v1` (`frequency_override`, dashboard-only presets) are silently ignored if sent.

## Order of refusals on creation

Before the campaign exists (the idempotency slot is released, a corrected retry with the same key is fine):

1. `422 invalid_request` — body fails the schema (missing `name`, `agent_id` or `calls_per_minute`, wrong types).
2. `422 idempotency_key_required`, `422 dnc_decision_invalid`.
3. `422 idempotency_key_reuse` / `409 idempotency_key_in_flight` / replay of the earlier answer.
4. `422 campaign_window_invalid`, then `422 campaign_open_ends_at_required` / `campaign_open_ends_at_too_far`.
5. `422 campaign_agent_unknown`, `422 campaign_timezone_unknown`.
6. Dialing binding: `422 dialing_route_unresolved` / `dialing_route_ambiguous` / `dialing_binding_incomplete`.
7. `422 working_hours_wider_than_law`.
8. Repository validation: `422 campaign_spec_invalid` (tier, retry configuration, working-hours form, duplicate action, name length), `422 campaign_classification_invalid`.

After the campaign exists (the slot is **kept** — a retry with the same key answers `409 idempotency_key_in_flight`; find the campaign with `GET /v1/campaigns`):

9. Planned calls: `409 market_package_not_installed`, `502 contact_file_storage_unavailable`, `422 contact_rows_rejected` (the rows could not be stored at all — distinct from rows rejected by the parser, which are reported inside `import` with a `201`).
10. Launch: `409 campaign_transition_invalid` (unexpected state race).

## Success response — `201 Created`

The response is **always `201`** when the campaign was created, whether or not dialing began. The single source of truth about that is `status`.

```json theme={null}
{
  "campaign_id": "cam-b9b536a86e",
  "status": "running",
  "created_via": "api",
  "launched_via": "api",
  "name": "june collections",
  "agent_id": "collections-agent",
  "timezone": "Asia/Almaty",
  "timezone_source": "request",
  "starts_at": null,
  "ends_at": null,
  "accepts_call_additions": false,
  "calls_per_minute": 10,
  "call_strategy": "standard",
  "classification": "marketing",
  "consent_tier": "pewc",
  "retry_config": { "attempts": 1, "interval_s": 900 },
  "duplicate_action": "reject",
  "use_contact_timezone": false,
  "working_hours": [{ "days": [0, 1, 2, 3, 4], "start": "09:00", "end": "19:00" }],
  "binding": { "trunk_id": "trk-31d4dc745a", "route_id": "rt-out-a", "from_number": "77000000001" },
  "import": {
    "import_id": "imp-353d158dd2",
    "status": "accepted",
    "rows_seen": 2,
    "accepted": 2,
    "warned": 0,
    "rejected": 0,
    "duplicates_found": 0,
    "dnc_matched": 0,
    "refusal_code": "",
    "issues": {},
    "dnc_screen": { "status": "clean", "checked_rows": 2, "unscreened_rows": 0, "matched_rows": 0, "suppressed_contacts": 0, "fingerprint": "20e2…", "items": [], "has_more": false, "hidden_rows": 0, "all_dialable_suppressed": false, "as_of_launch": true, "screened_at": 1788941847.27, "audit_id": 2, "decision": "clean", "import_id": "imp-353d158dd2" },
    "dnc_matches": []
  },
  "dnc_screen": { "status": "clean", "as_of_launch": true, "…": "same object as import.dnc_screen" },
  "blockers": [],
  "warnings": [
    { "code": "starts_immediately", "message": "…" },
    { "code": "frequency_policy_active", "rolling_7d_cap": 7, "daily_cap": 2, "source": "platform_default", "message": "…" }
  ]
}
```
