# Run a campaign

> Send texts, WhatsApp templates or AI calls to many people. Create a draft, preview who it reaches and what it costs, start exactly that, then watch each recipient.

import { Tabs, TabItem } from '@astrojs/starlight/components';

Send the same text, WhatsApp template or AI call to a whole audience, with
one body for every channel: only `content` changes. Create a draft, preview
it, then start exactly what you previewed.

Every create says `start`: `"draft"` saves it for preview, `"auto"` starts
it now. Without `start` it is `400 start_required`, and nothing is created.

**You need:** a number of your own that can send on the channel
([senders](/platform-api/sms/#pick-a-from-number); not the shared trial
number, and for SMS a Twilio number for now), an audience
([lists](#finding-ids)), and the
[compliance acknowledgment](#accept-the-outbound-compliance-acknowledgment)
accepted once. Every field: [reference](/platform-api/reference/campaigns/).

## Draft, preview, start

### 1. Create a draft

<Tabs syncKey="channel">
<TabItem label="SMS">

```bash
curl -X POST https://api.omniocto.com/v1/campaigns \
  -H "Authorization: Bearer $OMNI_OCTO_API_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "name": "Spring sale",
    "channel": "sms",
    "start": "draft",
    "from": "+16285550100",
    "content": { "text": "Hi {{first_name}}, 20% off today at {{shop}}. Your size {{shoe_size}} is in stock." },
    "audience": { "list": "cl-0000000001" },
    "variables": { "shop": "Main Street", "shoe_size": "{{contact.shoe_size|your size}}" }
  }'
```

</TabItem>
<TabItem label="WhatsApp">

```bash
curl -X POST https://api.omniocto.com/v1/campaigns \
  -H "Authorization: Bearer $OMNI_OCTO_API_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "name": "Spring sale (WhatsApp)",
    "channel": "whatsapp",
    "start": "draft",
    "from": "+16285550100",
    "content": { "template": { "name": "spring_sale", "language": "en_US" } },
    "audience": { "list": "cl-0000000001" },
    "variables": { "first_name": "{{contact.first_name|there}}", "shop": "Main Street" }
  }'
```

Or pick the template by its id, `"content": { "template": { "id": "wt-0000000001" } }`, as
[`GET /v1/templates`](/platform-api/whatsapp/#find-a-template) lists it. Give the id on its
own, or the name with its language; an id whose name or language disagrees is refused.

</TabItem>
<TabItem label="Voice">

```bash
curl -X POST https://api.omniocto.com/v1/campaigns \
  -H "Authorization: Bearer $OMNI_OCTO_API_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "name": "Appointment reminders",
    "channel": "voice",
    "start": "draft",
    "from": "+16285550100",
    "content": {
      "agent": "ag-0000000001",
      "mission": "Remind {{first_name}} of the appointment tomorrow at {{appt_time}} and ask if they need to reschedule."
    },
    "audience": { "segment": "sg-0000000001" },
    "variables": { "first_name": "{{contact.first_name}}", "appt_time": "{{contact.appt_time|the usual time}}" },
    "schedule": { "tz": "America/Los_Angeles", "days": [1, 2, 3, 4, 5], "hours": [{ "start": "09:00", "end": "18:00" }] },
    "limits": { "max_concurrent_calls": 5 },
    "retry": { "max_attempts": 3, "interval_min": 60, "on": ["no_answer", "voicemail"] },
    "call_settings": { "phone_menu": "never" }
  }'
```

</TabItem>
</Tabs>

A draft answers `201 Created` and sends nothing:

```json
{ "id": "00000000-0000-4000-8000-000000000007", "short_id": "ca-0000000001", "object": "campaign",
  "name": "Spring sale", "channel": "sms", "status": "draft", "paused_reason": null,
  "from": "+16285550100",
  "content": { "text": "Hi {{first_name}}, 20% off today at {{shop}}. Your size {{shoe_size}} is in stock.",
               "media": null, "track_links": false, "show_in_conversations": null },
  "audience": { "type": "list", "id": "cl-0000000001" },
  "variables": { "first_name": "{{contact.first_name}}", "shop": "Main Street",
                 "shoe_size": "{{contact.shoe_size|your size}}" },
  "schedule": null,
  "limits": { "spend_cap_credits": null, "max_concurrent_calls": null }, "retry": null,
  "counts": null, "metadata": null,
  "sms": { "engagement": null, "skipped_by_reason": null, "cost": null, "errors": null, "rate": null },
  "created_at": "2026-10-15T17:02:11.000Z", "started_at": null, "completed_at": null,
  "completed_reason": null }
```

Placeholders like `{{shop}}` are filled per person from `variables`: plain
text, or the contact's own field with a fallback. What fills by itself, and
who is skipped for a missing value, is in
[How the channels differ](/platform-api/overview/#how-the-channels-differ). Set
`track_links` (`true`, in `content`) counts clicks. For an MMS, set
`content.media` to the `media` that
[`POST /v1/media`](/platform-api/sms/#send-a-picture-mms) answers. Inline
`audience.contacts` or `audience.csv` are saved as contacts even for a draft
([make a list](#make-a-list) to try). The
list an inline audience is imported into (`source: "campaign"`) is removed
when you delete the draft, unless another campaign uses it; the contacts and
their opt-outs stay.

### 2. Preview it

```bash
curl -X POST https://api.omniocto.com/v1/campaigns/ca-0000000001/previews \
  -H "Authorization: Bearer $OMNI_OCTO_API_KEY"
```

```json
{ "id": "snap_b81c4e2a-6d8f-4a1b-9c3e-5f7a9b1d3e5f_1792083731000_9f3a6c1d2e8b", "object": "campaign_preview",
  "campaign_id": "00000000-0000-4000-8000-000000000007", "status": "ready",
  "recipients": 2, "skipped": 0, "skipped_by_reason": {},
  "cost_credits": 4, "balance_credits": null, "cost_estimate": null,
  "samples": [
    { "contact_id": "00000000-0000-4000-8000-0000000000a1", "contact_short_id": "ct-00000000a1",
      "name": "Ana Lopez", "to": "+14155550101",
      "text": "Hi Ana, 20% off today at Main Street. Your size 8 is in stock.", "segments": 1 },
    { "contact_id": "00000000-0000-4000-8000-0000000000a2", "contact_short_id": "ct-00000000a2",
      "name": "Sam Lee", "to": "+14155550102",
      "text": "Hi Sam, 20% off today at Main Street. Your size 10 is in stock.", "segments": 1 }
  ],
  "error": null, "freezes_audience": true }
```

- `id` is what you start with. An SMS preview's is `snap_…`; a WhatsApp or
  voice preview's is `rev_…`, like
  `rev_678b05f2-121b-45a0-b5df-a0a0ce2b02b5_1792435303068_4511bc52c922`. Pass
  it back whole, as it came.
- `recipients` is who will get it; `skipped_by_reason` says who will not, and
  why. Opted-out numbers are always skipped.
- `cost_credits` is what it will cost (for voice, an estimate;
  `cost_estimate` has its range).
- `balance_credits` (SMS only) is minus the shortfall, or `null` when your
  balance covers it; WhatsApp and voice always answer `null`.
- `samples` shows the message filled in for a few people (WhatsApp: with each
  button's link or copy code).
- Whether a start needs a preview, whether the preview freezes the audience
  and how long it stays usable depend on the channel:
  [How the channels differ](/platform-api/overview/#how-the-channels-differ).

#### Polling an SMS preview

The `POST` waits up to 5 seconds for an SMS preview to build. A larger
audience answers `"status": "building"`:

```json
{ "id": "snap_…", "object": "campaign_preview", "campaign_id": "…", "status": "building",
  "recipients": null, "skipped": null, "skipped_by_reason": null, "cost_credits": null,
  "balance_credits": null, "cost_estimate": null, "samples": null, "error": null,
  "freezes_audience": true, "poll_after_ms": 2000, "progress": { "done": 3000, "total": 12000 } }
```

Read it again with `GET /v1/campaigns/{id}/previews/{preview_id}` after
`poll_after_ms` (or `Retry-After`, in seconds) until it is `ready` or
`failed`. A build stalled for 2 minutes fails with `interrupted`: preview again.

A preview runs every check the start makes: the compliance acknowledgment,
balance, billing, calling minutes, sender, agent, content and audience. A preview
that cannot start the campaign answers `"status": "failed"`, with the code
the start would answer in `error` and every other problem in `error.also`,
each with a `hint` saying what to do. Fix them, then preview again. An
edit, or a change to something the preview checked (the sender, the
template, the agent, the compliance acknowledgment, the list), makes a
preview stale; a `PATCH` that changes nothing is not an edit. How long each
channel's preview stays usable:
[How the channels differ](/platform-api/overview/#how-the-channels-differ).

### 3. Start exactly what you reviewed

```bash
curl -X POST https://api.omniocto.com/v1/campaigns/ca-0000000001/start \
  -H "Authorization: Bearer $OMNI_OCTO_API_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "preview_id": "snap_b81c4e2a-6d8f-4a1b-9c3e-5f7a9b1d3e5f_1792083731000_9f3a6c1d2e8b" }'
```

It answers `200` with the campaign, `status: "queued"`; it moves to `running`
as sending starts.

- An SMS draft needs `preview_id`; WhatsApp and voice may start without one.
- A stale or failed preview is `409 preview_stale`: preview again.
- Send an `Idempotency-Key` ([why](/platform-api/retries/#idempotency)). A retry
  with it never starts twice.
- A draft missing something a start needs is `422 launch_fields_missing`, and
  `missing` lists the fields to set.

## Finding ids

Each field takes its own kind of id, or that thing's UUID
([ids](/platform-api/overview/#ids)):

| field | list it with | id |
|---|---|---|
| `from` | [`GET /v1/senders`](/platform-api/sms/#pick-a-from-number) | E.164, `pn-…`, or a channel's `ch-…` |
| `audience.list` | `GET /v1/lists` | `cl-…` |
| `audience.segment` | `GET /v1/segments` | `sg-…` |
| `content.template` | [`GET /v1/templates`](/platform-api/whatsapp/#find-a-template) | `wt-…` |
| `content.agent` | [`GET /v1/agents`](/platform-api/voice/#find-an-agent) | `ag-…` |
| `content.text` (SMS) | [`GET /v1/sms-templates`](/platform-api/sms/#use-a-saved-sms-template), its `text` | `tp-…` |

```bash
curl "https://api.omniocto.com/v1/lists?limit=20" -H "Authorization: Bearer $OMNI_OCTO_API_KEY"
```

```json
{ "object": "list", "has_more": false, "next_cursor": null,
  "data": [ { "id": "cl-0000000001", "object": "contact_list", "uuid": "9f3c…",
              "name": "VIP customers", "source": "dashboard", "contacts": 412,
              "created_at": "2026-10-01T16:20:00.000Z" } ] }
```

### Make a list

`POST /v1/lists` makes a list and imports its contacts, the same rows as
`audience.contacts`. A number a contact already has reuses that contact.
Each phone is stored for SMS, calls and WhatsApp alike, so one list serves a
campaign on any channel. Storing a number is not consent: opt-outs stay on
the number.

```bash
curl -X POST https://api.omniocto.com/v1/lists \
  -H "Authorization: Bearer $OMNI_OCTO_API_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "name": "Open house", "contacts": [ { "phone": "+14155550100", "name": "Ana" } ] }'
```

The answer is the list (`source: "api"`) with `import`, what the import did.
Pass its `id` as `audience.list`. `POST /v1/lists/{id}/contacts` adds more,
`GET /v1/lists/{id}/contacts` pages through them,
`DELETE /v1/lists/{id}/contacts/{contact}` takes one out (by `ct-…` id, or
by number when only one contact in the list has it), and
`DELETE /v1/lists/{id}` deletes the list, refused with `409 list_in_use`
while a campaign that has not finished uses it. A deleted list can no longer
be an `audience.list`. Every field:
[Contact lists reference](/platform-api/reference/lists/).

### Contacts

`/v1/contacts` keeps the people themselves: create and find them, give
them several numbers and emails, tag them, opt them out, and add the ones
you have to a list by id. See [Keep contacts](/platform-api/contacts/).

## The body

<a id="content"></a><a id="audience"></a><a id="variables"></a><a id="schedule"></a><a id="limits-and-retries"></a>

| field | what it is | rules |
|---|---|---|
| `content` | the text, the WhatsApp template, or the agent and mission | [Channel fields](/platform-api/reference/campaigns/#channel-fields) |
| `audience` | one of `list`, `segment`, `contacts`, `csv` | [Audience](/platform-api/reference/campaigns/#audience) |
| `variables` | how each placeholder is filled per person | [Variables](/platform-api/reference/campaigns/#variables) |
| `schedule` | when it may send or call | [Schedule](/platform-api/reference/campaigns/#schedule) |
| `limits`, `retry` | a spend cap (SMS and AI calls), calls at once and retries (voice) | [Limits and retries](/platform-api/reference/campaigns/#limits-and-retries) |
| `call_settings` | voice: phone menus, recording, hold limit, voicemail, knowledge base | [Call settings](#call-settings) |

## Call settings

`"phone_menu": "never"` turns phone-menu handling off
([fields](/platform-api/reference/campaigns/#call-settings)).
Running campaign:

```bash
curl -X POST https://api.omniocto.com/v1/campaigns/ca-0000000001/pause -H "Authorization: Bearer $OMNI_OCTO_API_KEY"
curl -X PATCH https://api.omniocto.com/v1/campaigns/ca-0000000001 \
  -H "Authorization: Bearer $OMNI_OCTO_API_KEY" -H "Content-Type: application/json" \
  -d '{ "call_settings": { "phone_menu": "never" } }'
curl -X POST https://api.omniocto.com/v1/campaigns/ca-0000000001/resume -H "Authorization: Bearer $OMNI_OCTO_API_KEY" -H "Idempotency-Key: $(uuidgen)"
```

## Watch it

```bash
curl https://api.omniocto.com/v1/campaigns/ca-0000000001 -H "Authorization: Bearer $OMNI_OCTO_API_KEY"
```

`status` ends `completed`, `failed` or `canceled`. `counts` has `recipients`,
`pending`, `sent`, `delivered`, `failed`, `skipped`, `replied` and
`opted_out` (`null` where a channel does not count them: voice has neither
of the last two, WhatsApp no `opted_out`). Rather than poll, subscribe to the
[campaign events](#events). What each count means:
[Reading a campaign](/platform-api/reference/campaigns/#reading-a-campaign). Cost:

```bash
curl https://api.omniocto.com/v1/campaigns/ca-0000000001/usage -H "Authorization: Bearer $OMNI_OCTO_API_KEY"
```

## Pause, resume, cancel

```bash
curl -X POST https://api.omniocto.com/v1/campaigns/ca-0000000001/pause -H "Authorization: Bearer $OMNI_OCTO_API_KEY"
curl -X POST https://api.omniocto.com/v1/campaigns/ca-0000000001/resume -H "Authorization: Bearer $OMNI_OCTO_API_KEY" -H "Idempotency-Key: $(uuidgen)"
curl -X POST https://api.omniocto.com/v1/campaigns/ca-0000000001/cancel -H "Authorization: Bearer $OMNI_OCTO_API_KEY"
```

Each answers the campaign with its new `status`. Pause and cancel are safe to
repeat. A resume takes an `Idempotency-Key` on every channel (send one). A campaign also pauses by itself,
when credits run out or it reaches its spend cap: `paused_reason` says why.
`DELETE /v1/campaigns/{id}` removes a draft, or a campaign canceled before
it sent anything; to stop a draft, delete it. A campaign that has sent keeps
its history: cancel it instead.

## Edit a campaign

`PATCH /v1/campaigns/{id}` edits a draft or a paused campaign; pause a
running one first. `content`, `variables`, `limits`, `retry` and
`call_settings` merge;
`schedule` and `metadata` are replaced; a name in `variables` set to `null`
is removed. A `null` on another channel's field (`"retry": null` on SMS) is
ignored. Preview again after any edit. On a voice campaign that has started,
`call_settings` reach the calls placed after a resume, but a new
`content.mission` does not: its calls keep their first mission.
[What each field does](/platform-api/reference/campaigns/#editing-patch).

An SMS campaign takes a new `from` only as a draft. An uncleared number is
accepted; preview and start refuse it (`sender_not_cleared`).

## Recipients

```bash
curl "https://api.omniocto.com/v1/campaigns/ca-0000000001/recipients?limit=500" \
  -H "Authorization: Bearer $OMNI_OCTO_API_KEY"
```

One row per person: `to`, `status`, `error`, `attempts`, and its message
(`message_id`) or call (`outcome`, `call_id`). Filter with `?status=`, page
with `limit` and `after`; [read one](/platform-api/transcripts/#one-campaign-recipient)
by `id`. [Every status](/platform-api/reference/campaigns/#recipients).

## Send a campaign in one request

With `"start": "auto"`, one request creates the campaign, runs every check a
start runs, and starts it. **It sends for real**, with no review step.

```bash
curl -X POST https://api.omniocto.com/v1/campaigns \
  -H "Authorization: Bearer $OMNI_OCTO_API_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "name": "Spring sale",
    "channel": "sms",
    "start": "auto",
    "from": "+16285550100",
    "content": { "text": "Hi {{first_name}}, 20% off today at {{shop}}." },
    "audience": { "contacts": [
      { "name": "Ana Lopez", "phone": "+14155550201" },
      { "name": "Ben Ito", "phone": "+14155550202" }
    ] },
    "variables": { "shop": "Main Street" }
  }'
```

It answers `202 Accepted` with the campaign: `queued`, `running`, or
`preparing` while a large SMS audience is checked. An empty audience is
`400 empty_audience_input`, and nothing is created. `?status=preparing`
lists the ones still being prepared. When everybody is
skipped it is `422 no_recipients` (or `all_opted_out` for SMS), and the
campaign is kept as a draft on every channel: `skipped_by_reason` says why
each person was left out, and `error.campaign_id` names the draft. Fix the
audience, then start the draft. A retry with the same `Idempotency-Key`
answers the same refusal for the same draft and creates no second campaign.
A start of a WhatsApp or voice draft whose audience reaches nobody is
`422 no_recipients` too.

## Accept the outbound compliance acknowledgment

A workspace's first campaign needs an owner or admin to accept the outbound
compliance acknowledgment once. Until then a start is
`409 outbound_compliance_required`; drafts are never gated. Read it, and show
`attestation` to the person accepting it:

```bash
curl https://api.omniocto.com/v1/compliance \
  -H "Authorization: Bearer $OMNI_OCTO_API_KEY"
```

```json
{ "object": "outbound_compliance", "accepted": false, "version": null,
  "current_version": "2026-09-01", "attestation": "…", "accepted_at": null }
```

Then accept the `current_version` you read:

```bash
curl -X POST https://api.omniocto.com/v1/compliance/accept \
  -H "Authorization: Bearer $OMNI_OCTO_API_KEY" -H "Content-Type: application/json" \
  -d '{ "version": "2026-09-01" }'
```

It answers the object with `accepted: true`, accepted as the key's owner.
When we change the text, `current_version` moves on and `accepted` is `false`
again. Accepting twice keeps the first acceptance. A stale `version` is
`409 version_mismatch`; a key whose owner is not an owner or admin gets
`403 insufficient_role`. You can also accept it in the dashboard under
[Settings → Compliance → Outbound](https://agent.omniocto.com/settings/compliance/outbound).
Every field: [Account](/platform-api/reference/account/).

## Events

Campaigns announce `campaign.started`, `campaign.paused`, `campaign.resumed`,
`campaign.completed` and `campaign.canceled` as webhooks and on the event
stream. SMS campaigns add `campaign.waiting` and `campaign.window_opened`
around their sending hours, and voice campaigns' calls add the call events.
Per-text and per-call updates reach only endpoints that name them. See
[Campaign events](/platform-api/events/#campaign-events).

To wait for one campaign, poll:

```bash
curl -s "https://api.omniocto.com/v1/events?wait=25&api_version=v2&campaign=ca-0000000001&types=campaign.completed,campaign.canceled" \
  -H "Authorization: Bearer $OMNI_OCTO_API_KEY"
```

See [Poll instead of stream](/platform-api/events/#poll-instead-of-stream).

## When it fails

Common codes ([how errors work](/platform-api/errors/)):

| status | code | what to do |
|---|---|---|
| 409 | `preview_required`, `preview_stale` | preview, then start with that `preview_id` |
| 409 | `outbound_compliance_required` | accept the acknowledgment above (`POST /v1/compliance/accept`) |
| 422 | `unmapped_variable` | fill every placeholder `fields` lists |
| 400 | `unknown_variable` | remove or rename the names `fields` lists (`422` on a WhatsApp or voice start or resume) |
| 402 | `insufficient_credits` | add credits; `short_credits` says how many, `GET /v1/balance` what you have |
| 409 | `campaign_not_editable` | pause it before a `PATCH` |

Every campaign code, with its fix: [Errors → Campaigns](/platform-api/errors/#campaigns).

## Next

- [Test without spending](/platform-api/testing/): a one-person campaign.
- [Receive webhooks](/platform-api/events/): follow it without polling.
- [Campaigns reference](/platform-api/reference/campaigns/): every field.