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

# Campaign fields

> Retries, numbers, dial binding, consent attestation, registry screening and idempotency.

# Campaign fields

## Retry configuration

`retry_config` is normalized on save and echoed back normalized.

| Field         | Type                      | Default   | Rules                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| ------------- | ------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `attempts`    | integer                   | `1`       | Total dial attempts per contact, 1–30. `0` reads as `1`; values above 30 are clamped to 30; negative → `422 campaign_spec_invalid`. **With the default, no number is redialed.**                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `interval_s`  | integer                   | `900`     | Pause between attempts in seconds. Must be `> 0` when `attempts > 1`, else `422 campaign_spec_invalid`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `retry_mode`  | `"regular"` \| `"custom"` | `regular` | `custom` uses the per-retry ladder `intervals_s`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `intervals_s` | array of integers         | —         | Seconds before each retry in `custom` mode (fitted to `attempts − 1`).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `rules`       | array                     | —         | `expert` only. Each rule: `{"id"?, "if", "then", "params"?}`. `if` is a `status_reason` value ([Call outcomes](/v4/web-api/call-status)) or `short_call` / `goal_not_achieved`; `then` is `wait_for_pass_end` or `stop_calling` (`retry_in` is accepted but not executed — warning `rule_action_unsupported`); `params`: `max_matches` (1–30, default 1) for any condition, `max_duration_s` (≥ 10, required) for `short_call`. Ids are assigned by the server (`r-xxxxxxxx`); ids you send that are not echoes of existing ones are replaced. A bare array of rules is accepted as the legacy form. |

## The numbers: `planned_calls`

Each object is one row of a contact list, and **its keys are column names**. Every column the file upload understands is understood here: market-package columns (`zip`, `state`, `city`), contract-group columns (`contract_id`, `role`), `timezone`, `name`, and any variable your agent's flow reads. A nested `variables` object is sugar and is flattened into the same columns (a top-level key wins over the same key inside `variables`). Values are converted to text (booleans → `true`/`false`, objects → compact JSON), so leading zeros survive.

```json theme={null}
{
  "planned_calls": [
    {"phone": "+77001234567", "name": "Aigerim", "variables": {"debt": "15000"}},
    {"phone": "+77007654321", "name": "Daniyar", "zip": "73301"}
  ]
}
```

A `phone` column is mandatory in every row; a value with fewer than ten digits is rejected as `phone_unreadable` — the **row** is rejected, not the request. Row-level issue codes are in [The upload report](/v4/web-api/imports).

## Dialing binding

You have no internal trunk or subgroup identifiers, so the platform resolves them: **omit `trunk_id`, `route_id` and `from_number` together**, and the subgroups bound to your agent are looked up across every trunk visible to your organization.

| Outcome              | Result                                                                                               |
| -------------------- | ---------------------------------------------------------------------------------------------------- |
| exactly one subgroup | it is used, together with the first number of its pool                                               |
| none                 | `422 dialing_route_unresolved`                                                                       |
| more than one        | `422 dialing_route_ambiguous`, with the candidates named in the message as `trunk_id/route_id` pairs |

Ambiguity is never resolved by picking the first candidate: dialing from another trunk would spend another caller ID and another quota, and you would find out from the invoice. Read the candidates with:

```
GET /v1/dialing-routes?agent_id=collections-agent
```

The response is `{"dialing_routes": [{"trunk_id", "trunk_name", "route_id", "route_name", "agents", "caller_numbers"}]}` — no trunk connection settings or credentials. An empty `agent_id` lists every route visible to your organization.

<Note>
  **`trunk_id` is the anchor.** Name it and the rest follows the platform's existing rules: the subgroup is resolved from the trunk and the agent at launch, and the caller ID is chosen by the subgroup itself, following its caller-ID policy. Naming `route_id` or `from_number` **without** `trunk_id` is refused with `422 dialing_binding_incomplete`: the platform will not guess which trunk you meant. A trunk named explicitly but unusable (agent not in its subgroup, empty pool) is not refused on creation — it becomes the `binding_invalid` blocker.
</Note>

## Attestation

Dialing requires a consent attestation: `{"attestation": {"confirmed": true}}`. The record is stored immutably against the version of the data it covers and the wording of the campaign's consent tier, and your API key is the signer — issuing the key is the human act the signature is attributed to (the platform records who issued the key and when).

A confirmation of one upload does **not** carry over to a new one; on `POST …/launch` the `attestation.import_id` field is therefore mandatory (→ `422 attestation_import_id_required`), and an `import_id` that is not the campaign's current import yields the `attestation_stale` blocker. Batches of added calls are signed separately.

## Do-not-call matches: `dnc_screen`

`dnc_screen` is the authoritative result. It screens every readable source row before market rejection and deduplication, so `matched_rows` may be larger than `suppressed_contacts`.

| Field                     | Type                            | Meaning                                                                                                                       |
| ------------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `status`                  | string                          | `clean` \| `matches` \| `screen_unavailable` (the import has no recorded source-row screening).                               |
| `checked_rows`            | integer \| null                 | Readable rows screened.                                                                                                       |
| `unscreened_rows`         | integer \| null                 | Rows with an unreadable phone, not screened.                                                                                  |
| `matched_rows`            | integer \| null                 | Rows whose number is on the registry (before deduplication).                                                                  |
| `suppressed_contacts`     | integer \| null                 | Distinct numbers matched.                                                                                                     |
| `fingerprint`             | string                          | Deterministic hash of the exact match composition; a `skip` decision is bound to it.                                          |
| `items`                   | array                           | Up to 200 matches: `{"row_number", "phone", "phone_key", "contact_name", "dnc_entry_id", "source", "reason", "recorded_at"}`. |
| `has_more`, `hidden_rows` |                                 | Whether `items` is truncated and by how much.                                                                                 |
| `all_dialable_suppressed` | boolean                         | Every pending contact is matched.                                                                                             |
| `as_of_launch`            | boolean                         | `false` — live against the registry; `true` — frozen snapshot taken at launch.                                                |
| `screened_at`, `audit_id` | number \| null, integer \| null | When the frozen snapshot was taken and its audit record.                                                                      |
| `decision`, `import_id`   | string                          | Frozen snapshot only: `clean` \| `skip`, and the import it covers.                                                            |

`import.dnc_matches` is a compatibility projection of `items` (the same objects plus a `row` alias of `row_number`); `import.dnc_matched` equals `matched_rows` (`0` when the screen is unavailable).

Matches block launch with `dnc_matches`. Sending `dnc: "skip"` records the same audited decision a human makes in the dashboard. It applies to this exact `import_id` **and fingerprint**: a new match or a replacement registry entry makes the decision stale, and launch-check/409 returns the refreshed screen. If every pending contact is matched, `all_contacts_suppressed` stays unconditional. `skip` with a clean screen records nothing and the launch proceeds normally.

The default `dnc: "block"` on an explicit launch is recorded as a decision too, and a later `block` revokes any earlier skip. An explicit launch that omits `dnc` therefore never rides on a skip somebody approved in the dashboard: pass `dnc: "skip"` yourself when that is what the call means. A decision needs an upload to apply to: `dnc` on a launch of a campaign without an import → `409 dnc_decision_without_upload`; on an import without a recorded projection → `409 dnc_screen_required`.

Before the first launch the screen and the matched-only report follow the live registry. The launch operation re-screens immediately before the transition and freezes all matched rows atomically with the import marker, attestation and campaign state. After launch, reads and reports use that snapshot (`as_of_launch: true`) even when the registry changes. Contacts matched mid-campaign are still suppressed at dial time; `progress` counts them separately.

## Idempotency of campaign requests

`POST /v1/campaigns` and `POST /v1/campaigns/{id}/calls` share one protocol:

* The key is reserved **before** the work starts, atomically. A second request with the same key while the first is still running gets `409 idempotency_key_in_flight` — wait for the first answer instead of retrying again.
* The body is fingerprinted alongside the key (canonical JSON, key order irrelevant). The same key with a **different** body is `422 idempotency_key_reuse`, never a silent replay of the earlier answer.
* The same key with the same body replays the stored `201` byte for byte.
* Keys are scoped to your API key, the endpoint and — for `calls` — the campaign, and remembered for 24 hours.
* On creation, a refusal **before** the campaign exists frees the key; a refusal **after** it exists keeps it. On `calls`, any refusal frees the key.
