Campaigns Deep-Dive

What you build: a complete mental model of the campaigns engine. Outbound 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.

StateMeaningTransitions
draftCreated, not dialing. Add targets here.start
runningActively dialing/sending at pacing.pause, cancel
pausedHalted; in-flight items finish, no new starts.resume, cancel, add targets
completedAll targets reached a terminal state.retry-failed
cancelledStopped 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:

1{
2 "channel": "voice",
3 "agent_id": "agt_…",
4 "config": {
5 "from_number": "+2341002223344",
6 "call_window": { "start": "09:00", "end": "18:00", "timezone": "Africa/Lagos" }
7 },
8 "retry_policy": { "max_attempts": 3, "backoff_seconds": 600, "backoff_multiplier": 4 },
9 "schedule": { "start_at": "2026-07-03T09:00:00Z" }
10}
FieldNotes
channelvoice, sms, or whatsapp (required)
agent_idRequired for voice; the agent runs the conversation
configOrchestrator-owned free-form template — caller-ID, call window, webhook, per-channel body
retry_policymax_attempts, backoff_seconds, backoff_multiplier. Absent = no retries
scheduleOptional 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:

$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" } }
> ]
> }'
1{ "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:

$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:

ChannelDisposition values
voicecompleted, failed, no_answer, busy
smssent, delivered, undeliv, opted_out
whatsappsent, delivered, read, failed, opted_out

Lifecycle control

$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:

1{
2 "id": "camp_6r…",
3 "status": "running",
4 "counters": { "total": 2, "queued": 0, "dispatched": 1, "succeeded": 1, "failed": 0 }
5}

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 rather than trusting your local count.

Webhook reference flow

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

ChannelEvents (in order)
voicecall.initiatedcall.transcription* → call.completed (with status, and the target’s disposition)
smsmessage.sentmessage.delivered or message.failed
whatsappmessage.sentmessage.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

SymptomLikely cause
retry-failed does nothingretryable_failed is 0 (no genuine dial failures), or campaign isn’t completed/paused.
Targets skipped_duplicate on addSame to_address already in the campaign.
Counters never reach completedSome targets still retrying under backoff; wait out the schedule or cancel.
Dialing pauses unexpectedlyOutside the config call window; resumes at window open.
A non-voice step blocks a later voice stepSee the in-flight-marker note in Multi-channel cascade.

Next steps