# Platform API (/v1) overview

> The Omni Octo REST API for SMS, WhatsApp, AI phone calls, campaigns, contacts, the inbox and webhooks, with one request shape, one status field and one error envelope for every channel.

The Platform API (`/v1`) lets your own app reach your customers through Omni Octo. You
send a text or a WhatsApp template, have an AI agent call someone, or run a
campaign to a whole list. Every channel uses the same field names, the same
`status` field, the same errors and the same idempotency rules.

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

```json
{ "object": "me",
  "workspace": { "id": "00000000-0000-4000-8000-00000000000f", "short_id": "ws-0000000001", "name": "Main Street Cafe" },
  "user_id": "00000000-0000-4000-8000-000000000010", "user_phone": "+14155550123",
  "api_key": { "id": "00000000-0000-4000-8000-000000000011", "role": "owner", "expires_at": null } }
```

## Start here

| to | guide | fields | first success |
|---|---|---|---|
| go from a key to a text, a webhook and a campaign | [Quickstart](/platform-api/quickstart/) | | about ten requests |
| hear an AI agent call you | [Call your own phone](/platform-api/voice/#call-your-own-phone) | [Calls](/platform-api/reference/calls/) | about 3 minutes, no card |
| send a text | [Send a text](/platform-api/sms/) | [Messages](/platform-api/reference/messages/) | after carrier registration, 1 to 3 weeks |
| send a WhatsApp template | [Send a WhatsApp template](/platform-api/whatsapp/) | [Messages](/platform-api/reference/messages/) | once a number and a template are approved |
| reach a whole list | [Run a campaign](/platform-api/campaigns/) | [Campaigns](/platform-api/reference/campaigns/) | a one-person test first |
| keep your contacts, tags and opt-outs | [Keep contacts](/platform-api/contacts/) | [Contacts](/platform-api/reference/contacts/) | right away |
| read and answer your inbox | [Work the inbox](/platform-api/inbox/) | [Conversations](/platform-api/reference/conversations/) | right away |
| hear back: deliveries, replies, calls | [Receive webhooks](/platform-api/events/) | [Webhooks](/platform-api/reference/webhooks/) | minutes, with a test event |
| try it all without spending | [Test without spending](/platform-api/testing/) | | right away |
| drive it from Claude Code or another MCP client | [API tools (MCP)](/mcp/api-tools/), [call campaigns from Claude Code](/mcp/claude-code-campaigns/) | | one command |

What every code means: [Errors](/platform-api/errors/). Every field of every
endpoint: [Reference](/platform-api/reference/).

## Environments

| environment | API (`/v1`) | dashboard (keys, numbers, billing) |
|---|---|---|
| production | `https://api.omniocto.com` | `https://agent.omniocto.com` |
| development | `https://api.agent-dev.omniocto.com` | `https://agent-dev.omniocto.com` |

- **A key belongs to one environment and one workspace.** Create it in that
  environment's dashboard. A production key sent to development is
  `401 invalid_api_key`, and so is the other way round.
- **`/v1` answers only on the API host.** The same path on a dashboard host
  is `404`.
- **The examples use production.** To run them elsewhere, put the base URL
  in a variable and write `$OMNI_OCTO_API_URL` where they write
  `https://api.omniocto.com`, as the [quickstart](/platform-api/quickstart/#environments)
  does.

## The resources

| resource | what it does | guide |
|---|---|---|
| `/v1/messages` | Send one SMS/MMS or one approved WhatsApp template, read its status, list what you sent. | [Send a text](/platform-api/sms/), [Send a WhatsApp template](/platform-api/whatsapp/) |
| `/v1/calls` | An AI agent calls one number and works on a `mission` you write; read or list calls, end one, download its voicemail. | [Place a call](/platform-api/voice/) |
| `/v1/campaigns` | Send texts, WhatsApp templates or AI calls to many people: create, preview, start, pause, watch. | [Run a campaign](/platform-api/campaigns/) |
| `/v1/senders` | The numbers you can send and call from, and whether each one is ready on each channel. | [Send a text](/platform-api/sms/#pick-a-from-number) |
| `/v1/templates` | Your WhatsApp templates: the `wt-…` id to send, the number each sends from, and what each needs filled in. | [Send a WhatsApp template](/platform-api/whatsapp/#find-a-template) |
| `/v1/agents` | Your AI agents and their `ag-…` ids, for calls and voice campaigns. | [Place a call](/platform-api/voice/#find-an-agent) |
| `/v1/lists` | Contact lists (`cl-…`) for a campaign's `audience.list`: create one with its contacts, add more, take one out, delete it. | [Run a campaign](/platform-api/campaigns/#make-a-list) |
| `/v1/contacts` | The people you text and call (`ct-…`): create, find, search, change and delete them; their numbers and emails, tags and opt-outs. | [Keep contacts](/platform-api/contacts/) |
| `/v1/contact-fields` | The custom contact fields, the keys of a contact's `attributes`. | [Keep contacts](/platform-api/contacts/#custom-fields) |
| `/v1/conversations` | Your inbox on every channel (`cv-…`): list and read conversations and their messages, reply in one, hand it off or close it, search what was said. | [Work the inbox](/platform-api/inbox/) |
| `/v1/segments` | Saved segments (`sg-…`): a filter over your contacts, for a campaign's `audience.segment`. Create, change, delete, and see who is in one. | [Build a segment](/platform-api/segments/) |
| `/v1/balance` | The credit balance: what can be spent now, and whether the workspace is suspended. | [Credits and balance](#credits-and-balance) |
| `/v1/usage` | What the credits went on: totals by category, day or label, one row per charge, a CSV export. | [Usage and spend](#usage-and-spend) |
| `/v1/compliance` | The outbound compliance acknowledgment a workspace's first campaign needs: read it, accept it. | [Run a campaign](/platform-api/campaigns/#accept-the-outbound-compliance-acknowledgment) |

`GET /v1/me` names the workspace and the key a request runs as, when the key
expires (`api_key.expires_at`, `null` when it never does), and the phone
number on the key owner's profile (`user_phone`), the number the shared trial
number can call. Delivery
receipts and replies reach you as webhooks or on an event stream: see
[Receive webhooks](/platform-api/events/).

## One shape for every channel

These rules hold on the messaging endpoints: `/v1/messages`, `/v1/calls`,
`/v1/campaigns`, `/v1/senders`, `/v1/me`, `/v1/templates`, `/v1/agents`,
`/v1/lists`, `/v1/contacts`, `/v1/contact-fields`, `/v1/conversations`,
`/v1/segments`, `/v1/balance`, `/v1/usage`, `/v1/compliance`,
`/v1/webhooks`, `/v1/events`, `/v1/media` and `/v1/recordings`. The
OpenAI-compatible routes keep the OpenAI format: [Errors](/platform-api/errors/).

- **Base URL:** `https://api.omniocto.com` in production; the other
  environment is in [Environments](#environments).
- **Authentication:** send a workspace API key as
  `Authorization: Bearer sk-octo_…`. Create one in the dashboard under
  [Settings → API Keys & Webhooks](https://agent.omniocto.com/settings/api-keys);
  see [Get an API key](/getting-started/api-keys/). A key works in the
  workspace it was created in. A missing or bad key is `401 invalid_api_key`,
  whose `docs_url` is [Authentication](/getting-started/authentication/).
- **JSON in, JSON out.** Send `Content-Type: application/json`. Field names are
  snake_case and every time is an ISO 8601 string. The one exception is not
  in a body: a webhook delivery's `webhook-timestamp` header is Unix seconds,
  as Standard Webhooks defines it.
- **Strict bodies and query strings.** A field a route does not take is
  refused with `400 unknown_field`, `param` names it, and `hint` suggests the
  field you most likely meant. A query parameter a route does not take is
  `400 unknown_parameter`, the same way, and a parameter sent twice
  (`?limit=1&limit=5`) is `400 invalid_request` ("pass `limit` once").
  No request-body field and no query parameter is silently dropped, so a
  misspelled `media` never turns into a text without its picture, and a
  `?limit` a route does not page by is never ignored. One body field is
  passed on as it is: a WhatsApp message's `template.components` is Meta's
  own array, and its entries are checked against the approved template
  (`422 template_components_mismatch`), not against a schema of ours.
- **Methods.** A messaging path called with a method it does not take
  (`PUT /v1/campaigns/{id}`) is `405 method_not_allowed`, with an `Allow`
  header listing the methods it does take. A path that does not exist is
  `404 not_found`.
- **`channel` picks the body.** `POST /v1/messages` and `POST /v1/campaigns`
  take `channel` (`sms` or `whatsapp`, plus `voice` for campaigns), and the
  rest of the body follows from it. New channels may be added later: ignore
  values you do not know.
- **One `status` field** on every message, call and campaign. Each guide lists
  its values.
- **One error envelope:** `{ "error": { "type", "code", "message", "param", … } }`.
  Branch on `code`. See [Errors](/platform-api/errors/).
- **`Idempotency-Key`** on the requests that send, call or start something. A
  retry with the same key does not act twice. See
  [Idempotency](/platform-api/retries/#idempotency).
- **Lists** answer `{ "object": "list", "data", "has_more", "next_cursor" }`
  and page with `limit` and `after`: [Lists and paging](#lists-and-paging).

## Lists and paging

Every list answers `{ "object": "list", "data", "has_more", "next_cursor" }`,
newest first unless the reference says otherwise (a contact's numbers and
emails come primary first; custom contact fields in the dashboard's order).
Pass `next_cursor` as `after` for the next page; it is `null` on the last
page. `GET /v1/senders` is the one exception: it answers every number at once,
`has_more` is always `false`, and it takes neither `limit` nor `after`.

| list | `limit` | default |
|---|---|---|
| most lists | 1 to 200 | 50 |
| a contact's numbers and emails | 1 to 200 | 100 |
| campaign recipients | 1 to 1,000 | 100 |
| message search | 1 to 100 | 20 |

A `limit` out of range is `400 invalid_request`.

**A cursor is opaque and belongs to the list that answered it.** Pass it back
as it is, to the same list (the same campaign's recipients, for a recipients
cursor), **with the same filters** (`channel`, `to`, `from`, `status`,
`campaign`, `created_after`, `created_before`, whichever you sent). A filter
written another way that means the same thing (`2026-10-16T11:00:00+02:00` for
`2026-10-16T09:00:00Z`, a `pn-…` id for its number) keeps the cursor. Anything
else, a cursor of another list or the same list with other filters, is
`400 invalid_cursor`, with a `hint` saying which.

## How the channels differ

The request shapes are the same; these are the things that are not. Each
cell links to, or is, the rule; the guides do not repeat it.

| | SMS | WhatsApp | voice |
|---|---|---|---|
| one send | `POST /v1/messages`, `"channel": "sms"` | `POST /v1/messages`, `"channel": "whatsapp"`, an approved template only | `POST /v1/calls`: an AI agent works on your `mission` |
| sender in `GET /v1/senders` | `sms.ready`; Twilio numbers only for now, texts and campaigns alike ([why](/platform-api/sms/#pick-a-from-number)) | `whatsapp.ready` | `voice.ready`. The shared trial number (`pfn-…`) places single calls only to a phone number on your profile or a teammate's (`GET /v1/me` `user_phone`), never a campaign |
| placeholders | `{{name}}` in `content.text`; `first_name`, `last_name`, `full_name`, `email` and `phone` fill by themselves | the template's `variable_keys`; nothing fills by itself | `{{name}}` in `content.mission`; nothing in a mission fills by itself |
| a contact with no value | `{{contact.<field>\|fallback}}` fills it; with no fallback the person is skipped as `missing_variable:<name>` | skipped the same way | skipped the same way (a variable the mission uses) |
| preview | waits up to 5 s, then may answer `building`: [poll](/platform-api/campaigns/#polling-an-sms-preview) | answers at once | answers at once |
| a start without a preview | refused, `409 preview_required` | allowed: checked as a preview would be | allowed, as on WhatsApp |
| the audience | frozen by the preview: the start sends to exactly those people | read again at the start | read again at the start |
| a preview stays usable | 30 minutes, until a newer preview, an edit or a change to what it checked; after that it reads `failed` with `preview_expired`, and a start from it is `409 preview_stale` | until an edit or a change to what it checked | as on WhatsApp |
| what a preview says it costs | `cost_credits`, the built snapshot's cost; `balance_credits` shows a shortfall | `cost_credits`: 0.1 credits a recipient | `cost_credits` is the high end of `cost_estimate` (`low`, `high`, per call, talk minutes) |
| price | per segment ([SMS prices](/platform-api/billing/#sms-and-mms)) | 0.1 credits a message ([pricing](/platform-api/billing/#whatsapp)) | by the minute (Settings → Billing) |
| `status` | `queued`, `sending`, `sent`, `delivered`, `undelivered`, `failed`, `skipped`, `unconfirmed` ([meanings](/platform-api/sms/#read-a-texts-status)) | the same words, plus `read`, without `undelivered` ([what differs](/platform-api/whatsapp/#status)) | `queued`, `ringing`, `in_progress`, `completed`, `voicemail`, `no_answer`, `busy`, `failed` ([meanings](/platform-api/voice/#read-a-call)) |
| list filters | `GET /v1/messages?channel=sms`: `to`, `from`, `status`, `campaign`, `created_after`, `created_before` | `?channel=whatsapp`: the same six | `GET /v1/calls`: the same six |
| events | `message.status_updated`; `message.received` (replies) and `message.sent` (what your agent or team texted) | `message.status_updated` (with `read`); `message.received` (replies, [what fires](/platform-api/whatsapp/#hear-replies)) | `call.ringing`, `call.answered`, `call.completed`, `call.failed` |
| campaign events | `campaign.*`, plus `campaign.waiting` and `campaign.window_opened` around sending hours | `campaign.*` | `campaign.*`, and each call's `call.*` |

The preview in detail: [preview rules](/platform-api/reference/campaigns/#previews).
Placeholders in detail: [Variables](/platform-api/reference/campaigns/#variables).

## Phone numbers

Every phone number, in a body or an answer, is **E.164**: `+`, the country
code and the number, with no spaces or punctuation (`+14155550100`).
`415-555-0100` and `+1 415 555 0100` are `400 invalid_number`, and nothing is
sent or charged. When the intended number is clear, the `hint` says
`Did you mean +14155550100?`; we never send to the suggestion, so send again
with it. **In a query string, write the `+` as `%2B`**
(`GET /v1/contacts?phone=%2B14155550100`): a bare `+` reads as a space.

## Ids

| resource | `id` | also | every `{id}` route takes |
|---|---|---|---|
| campaign | the UUID | `short_id`: `ca-…` | either |
| message | the UUID | | the UUID |
| call | the UUID | (its conversation's `cv-…`) | either |
| agent, contact list, segment, WhatsApp template | `ag-…`, `cl-…`, `sg-…`, `wt-…` | `uuid` (agents, lists, segments) | either, where a body names one |
| sender | `pn-…` (a number), `ch-…` (a WhatsApp-only number), `pfn-…` (the shared trial number) | `e164`, `channel_uuids` | see `from` below |

**`from` takes the same forms on every channel**: the number's E.164
(`+16285550100`), its `pn-…` id, the `ch-…` id of one of its channels (the
SMS, voice or WhatsApp channel; `whatsapp.channel_id` in `GET /v1/senders`),
or the UUID of the number or the channel. It must be able to send on the
channel you use, or it is `400 invalid_from`. A single call also takes the
shared trial number's `pfn-…` id.

- **Either form works.** Wherever a resource has both a short id and a UUID,
  every route and body field that names it takes either. A campaign keeps
  its UUID as `id` (with `short_id` beside it) because events carry that UUID.
  A message has only its UUID, so a short id in `/v1/messages/{id}` is `400`.
- **The wrong kind is `400`** wherever an id goes, naming the prefix the field
  takes and the route that lists them: in a body field (`sg-…` where
  `audience.list` wants a `cl-…`), a path (`/v1/campaigns/cl-…`) or a query
  parameter (`?campaign=cl-…`) it is `invalid_request`, with `param` naming
  the field, `id` or the parameter. As `from` (`cl-…` where a number goes) it
  keeps `from`'s own code, `invalid_from`.
- **A right-kind id that does not exist in your workspace is `404`.**

## The shared trial number

The shared trial number (`trial: true` in `GET /v1/senders`) places test calls to
a phone number on your profile or a teammate's, with no card. Its rules:
[The trial number](/platform-api/voice/#the-trial-number).

## Testing without spending

There is no sandbox or test mode yet. What is free, what costs a little, and
how to test on yourself: [Test without spending](/platform-api/testing/).

## Credits and balance

Sends and calls are paid in credits from the workspace's balance. The balance,
what each channel costs, the `402` refusals and the usage report:
[Billing and credits](/platform-api/billing/).

### Usage and spend

`GET /v1/usage`, `/v1/usage/events` and `/v1/usage/export`:
[Usage and spend](/platform-api/billing/#usage-and-spend).

## Limits

| what | limit |
|---|---|
| request body | 256 KB for `/v1/messages` and `/v1/calls`; 5 MB of audience plus a small envelope for `/v1/campaigns`. Over it: `413 payload_too_large` |
| list page size (`limit`) | 1 to 200, default 50; campaign recipients 1 to 1,000, default 100 |
| SMS text | 1,600 characters |
| call `mission` | 4,000 characters, for a single call and a campaign |
| campaign audience | 1 to 10,000 inline `contacts`, or a CSV of up to 5 MB |
| a campaign variable | plain text, a field name or a fallback: 200 characters each; a whole value: 420 |
| `metadata` | string values only; at most 20 keys, each key 1 to 40 characters, each value at most 500 characters (messages, calls, campaigns) |
| `Idempotency-Key` | 1 to 255 printable ASCII characters, kept 24 hours |

Request and sending rates (per key, per workspace, per number) are in
[Rate limits](/platform-api/retries/#rate-limits).

## Versioning

`/v1` is stable. Within it we only add: new routes, new optional fields in a
request, new fields in an answer, new `status` and `code` values and new
event types. Ignore what you do not know, and branch on `code`, not on
`message`. A change that could break working code (removing or renaming a
field, changing what a field means, refusing a request we accepted) comes
with a new version or, for a fix, a notice in the
[changelog](/changelog/) before it ships. Every change is in the changelog.

## Reference

Every endpoint in one page of tables: [Reference](/platform-api/reference/).
Every operation, with its request and response schema and a try-it console:
[API reference](/openapi/v1/).

The dashboard runs on its own REST API under `/api/*`. It accepts the same API
key and reaches what the Platform API does not cover, such as uploading an
MMS picture and buying phone numbers:
[Dashboard API reference](/openapi/platform/).