> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.voicebip.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.voicebip.com/_mcp/server.

# Campaigns Deep-Dive

> The outbound orchestrator as a primitive — full lifecycle, per-recipient params, retries, counters, and the webhook reference flow for tracking every target.

**What you build:** a complete mental model of the campaigns engine. [Outbound reminder campaign](/use-cases/reminder-campaign) is the "just make it work" recipe; this page is the reference — every lifecycle state, every knob, and how to reconcile counters against webhooks so your dashboard is never wrong.

**Who it's for:** teams running outbound at scale who need to pause/resume safely, retry only failures, personalise every recipient, and reconcile delivery.

## Lifecycle states

```
draft ──start──▶ running ──pause──▶ paused ──resume──▶ running ──▶ completed
   │                 │                                     │
   │                 └────────────── cancel ──────────────┴──▶ cancelled
   └── (add targets while draft or paused)
```

`status` enum: `draft`, `running`, `paused`, `completed`, `cancelled`.

| State       | Meaning                                        | Transitions                     |
| ----------- | ---------------------------------------------- | ------------------------------- |
| `draft`     | Created, not dialing. Add targets here.        | `start`                         |
| `running`   | Actively dialing/sending at pacing.            | `pause`, `cancel`               |
| `paused`    | Halted; in-flight items finish, no new starts. | `resume`, `cancel`, add targets |
| `completed` | All targets reached a terminal state.          | `retry-failed`                  |
| `cancelled` | Stopped by you; remaining targets skipped.     | —                               |

## Create with the full config

Only `channel` is required. Execution details (caller-ID, call window, per-channel body) live in the free-form, orchestrator-owned `config`; retries in `retry_policy`; optional scheduling in `schedule`:

```json
{
  "channel": "voice",
  "agent_id": "agt_…",
  "config": {
    "from_number": "+2341002223344",
    "call_window": { "start": "09:00", "end": "18:00", "timezone": "Africa/Lagos" }
  },
  "retry_policy": { "max_attempts": 3, "backoff_seconds": 600, "backoff_multiplier": 4 },
  "schedule": { "start_at": "2026-07-03T09:00:00Z" }
}
```

| Field          | Notes                                                                                     |
| -------------- | ----------------------------------------------------------------------------------------- |
| `channel`      | `voice`, `sms`, or `whatsapp` (required)                                                  |
| `agent_id`     | Required for `voice`; the agent runs the conversation                                     |
| `config`       | Orchestrator-owned free-form template — caller-ID, call window, webhook, per-channel body |
| `retry_policy` | `max_attempts`, `backoff_seconds`, `backoff_multiplier`. Absent = no retries              |
| `schedule`     | Optional deferred start                                                                   |

Returned object carries `id` (`camp_{nanoid}`), `status`, `counters`, timestamps, and `retryable_failed`.

## Per-recipient parameters

Every target has a `to_address` and its own `params`, referenced in the agent prompt or SMS body. This is how one campaign produces personalised contact for thousands of recipients:

```bash
curl -X POST https://api.voicebip.com/v1/campaigns/camp_6r…/targets \
  -H "Authorization: Bearer pk_xxx" -H "Content-Type: application/json" \
  -d '{
    "targets": [
      { "to_address": "+2348031234567", "params": { "name": "Ada", "amount": "₦12,500", "due": "5 July" } },
      { "to_address": "+2348082223344", "params": { "name": "Bode", "amount": "₦8,000", "due": "6 July" } }
    ]
  }'
```

```json
{ "added": 2, "skipped_duplicate": 0, "rejected_invalid": 0 }
```

`skipped_duplicate` = a `to_address` already in the campaign; `rejected_invalid` = malformed E.164. List targets and their per-target state any time:

```bash
curl "https://api.voicebip.com/v1/campaigns/camp_6r…/targets?status=failed&limit=100" \
  -H "Authorization: Bearer pk_xxx"
```

A `CampaignTarget` carries: `id` (`tgt_{nanoid}`), `campaign_id`, `to_address`, `params`, `disposition`, `attempt_count`, `next_attempt_at`, `last_exec_id` (the `call_`/`msg_` id of the latest execution). Disposition is channel-specific:

| Channel  | Disposition values                                 |
| -------- | -------------------------------------------------- |
| voice    | `completed`, `failed`, `no_answer`, `busy`         |
| sms      | `sent`, `delivered`, `undeliv`, `opted_out`        |
| whatsapp | `sent`, `delivered`, `read`, `failed`, `opted_out` |

## Lifecycle control

```bash
curl -X POST https://api.voicebip.com/v1/campaigns/camp_6r…/start   -H "Authorization: Bearer pk_xxx"
curl -X POST https://api.voicebip.com/v1/campaigns/camp_6r…/pause   -H "Authorization: Bearer pk_xxx"
curl -X POST https://api.voicebip.com/v1/campaigns/camp_6r…/resume  -H "Authorization: Bearer pk_xxx"
curl -X POST https://api.voicebip.com/v1/campaigns/camp_6r…/cancel  -H "Authorization: Bearer pk_xxx"

# after completion, re-attempt only the targets that genuinely failed (fresh retry cycle)
curl -X POST https://api.voicebip.com/v1/campaigns/camp_6r…/retry-failed -H "Authorization: Bearer pk_xxx"
```

`retry-failed` re-queues the `retryable_failed` targets (genuine dial failures, excluding suppression terminals like `opted_out`). The campaign object surfaces that count as `retryable_failed` for completed/paused campaigns (omitted when 0), so you know how many `retry-failed` will touch.

## Counters and reconciliation

The campaign object carries live counters. Treat webhooks as the source of truth for *why* a target landed where it did, and counters as the fast aggregate:

```json
{
  "id": "camp_6r…",
  "status": "running",
  "counters": { "total": 2, "queued": 0, "dispatched": 1, "succeeded": 1, "failed": 0 }
}
```

The invariant to check in your dashboard:

```
total == queued + dispatched + succeeded + failed
```

If your own tally drifts from `counters`, you dropped or double-counted a webhook — reconcile against [delivery history](/guides/webhooks) rather than trusting your local count.

## Webhook reference flow

Per target, you'll see (channel-dependent):

| Channel  | Events (in order)                                                                                            |
| -------- | ------------------------------------------------------------------------------------------------------------ |
| voice    | `call.initiated` → `call.transcription`\* → `call.completed` (with `status`, and the target's `disposition`) |
| sms      | `message.sent` → `message.delivered` **or** `message.failed`                                                 |
| whatsapp | `message.sent` → `message.delivered` → (`read`\*)                                                            |

`*` optional/best-effort. Every campaign event carries `campaign_id` and the target's address, so you can attribute it. Increment your tallies off the **terminal** event only (`call.completed` / `message.delivered` / `message.failed`), never the intermediate ones.

## Test it (sandbox)

Run an entire campaign with `pk_test_` and targets in the test range (`+234800000xxxx`). The orchestrator paces and "dials" synthetically, emitting real-signed lifecycle events with ₦0 billing — end-to-end rehearsal of pacing, retries, and your reconciliation code without touching a carrier.

## Troubleshooting

| Symptom                                    | Likely cause                                                                                |
| ------------------------------------------ | ------------------------------------------------------------------------------------------- |
| `retry-failed` does nothing                | `retryable_failed` is 0 (no genuine dial failures), or campaign isn't `completed`/`paused`. |
| Targets `skipped_duplicate` on add         | Same `to_address` already in the campaign.                                                  |
| Counters never reach `completed`           | Some targets still retrying under `backoff`; wait out the schedule or `cancel`.             |
| Dialing pauses unexpectedly                | Outside the `config` call window; resumes at window open.                                   |
| A non-voice step blocks a later voice step | See the in-flight-marker note in [Multi-channel cascade](/use-cases/multi-channel-cascade). |

## Next steps

* The quick, opinionated version → [Outbound reminder campaign](/use-cases/reminder-campaign).
* Fall back across channels within one flow → [Multi-channel cascade](/use-cases/multi-channel-cascade).
* Personalise what each call *says* with tools → [Tool-calling](/use-cases/tool-calling).