Run a campaign
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; not the shared trial number, and for SMS a Twilio number for now), an audience (lists), and the compliance acknowledgment accepted once. Every field: reference.
Draft, preview, start
Section titled “Draft, preview, start”1. Create a draft
Section titled “1. Create a draft”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}}" } }'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 lists it. Give the id on its
own, or the name with its language; an id whose name or language disagrees is refused.
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" } }'A draft answers 201 Created and sends nothing:
{ "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. Set
track_links (true, in content) counts clicks. For an MMS, set
content.media to the media that
POST /v1/media answers. Inline
audience.contacts or audience.csv are saved as contacts even for a draft
(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
Section titled “2. Preview it”curl -X POST https://api.omniocto.com/v1/campaigns/ca-0000000001/previews \ -H "Authorization: Bearer $OMNI_OCTO_API_KEY"{ "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 }idis what you start with. An SMS preview’s issnap_…; a WhatsApp or voice preview’s isrev_…, likerev_678b05f2-121b-45a0-b5df-a0a0ce2b02b5_1792435303068_4511bc52c922. Pass it back whole, as it came.recipientsis who will get it;skipped_by_reasonsays who will not, and why. Opted-out numbers are always skipped.cost_creditsis what it will cost (for voice, an estimate;cost_estimatehas its range).balance_credits(SMS only) is minus the shortfall, ornullwhen your balance covers it; WhatsApp and voice always answernull.samplesshows 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.
Polling an SMS preview
Section titled “Polling an SMS preview”The POST waits up to 5 seconds for an SMS preview to build. A larger
audience answers "status": "building":
{ "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.
3. Start exactly what you reviewed
Section titled “3. Start exactly what you reviewed”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). A retry with it never starts twice. - A draft missing something a start needs is
422 launch_fields_missing, andmissinglists the fields to set.
Finding ids
Section titled “Finding ids”Each field takes its own kind of id, or that thing’s UUID (ids):
| field | list it with | id |
|---|---|---|
from | GET /v1/senders | 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 | wt-… |
content.agent | GET /v1/agents | ag-… |
content.text (SMS) | GET /v1/sms-templates, its text | tp-… |
curl "https://api.omniocto.com/v1/lists?limit=20" -H "Authorization: Bearer $OMNI_OCTO_API_KEY"{ "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
Section titled “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.
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.
Contacts
Section titled “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.
The body
Section titled “The body”| field | what it is | rules |
|---|---|---|
content | the text, the WhatsApp template, or the agent and mission | Channel fields |
audience | one of list, segment, contacts, csv | Audience |
variables | how each placeholder is filled per person | Variables |
schedule | when it may send or call | Schedule |
limits, retry | a spend cap (SMS and AI calls), calls at once and retries (voice) | Limits and retries |
call_settings | voice: phone menus, recording, hold limit, voicemail, knowledge base | Call settings |
Call settings
Section titled “Call settings”"phone_menu": "never" turns phone-menu handling off
(fields).
Running campaign:
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
Section titled “Watch it”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. What each count means:
Reading a campaign. Cost:
curl https://api.omniocto.com/v1/campaigns/ca-0000000001/usage -H "Authorization: Bearer $OMNI_OCTO_API_KEY"Pause, resume, cancel
Section titled “Pause, resume, cancel”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
Section titled “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.
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
Section titled “Recipients”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
by id. Every status.
Send a campaign in one request
Section titled “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.
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
Section titled “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:
curl https://api.omniocto.com/v1/compliance \ -H "Authorization: Bearer $OMNI_OCTO_API_KEY"{ "object": "outbound_compliance", "accepted": false, "version": null, "current_version": "2026-09-01", "attestation": "…", "accepted_at": null }Then accept the current_version you read:
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.
Every field: Account.
Events
Section titled “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.
To wait for one campaign, poll:
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"When it fails
Section titled “When it fails”Common codes (how errors work):
| 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.
- Test without spending: a one-person campaign.
- Receive webhooks: follow it without polling.
- Campaigns reference: every field.