# Send a text

> Send a text or a picture with POST /v1/messages, get your number cleared to text, read its status, and handle the refusals you will meet.

Send one text message (SMS) or picture (MMS) from one of your numbers, and
follow it until the carrier delivers it.

**You need:** an [API key](/getting-started/api-keys/), a number
[cleared to text](#get-a-number-cleared-to-text), and
[credits](#credits-and-pricing). Every field:
[reference](/platform-api/reference/messages/#messagecreate).

## Recipe: text someone and read the reply

Five commands that run in order as pasted. Set these first (the events read
needs a key made by an owner or admin):

```bash
export OMNI_OCTO_API_URL=https://api.omniocto.com
export OMNI_OCTO_API_KEY=sk-octo_...
```

**1. Mark where the event log is now**, so step 5 sees only what happens after:

```bash
CURSOR=$(curl -s "$OMNI_OCTO_API_URL/v1/events?wait=0" \
  -H "Authorization: Bearer $OMNI_OCTO_API_KEY" | jq -r .next_cursor)
```

**2. Find a number that can text** (`sms.ready`; [more](#pick-a-from-number)):

```bash
FROM=$(curl -s "$OMNI_OCTO_API_URL/v1/senders" -H "Authorization: Bearer $OMNI_OCTO_API_KEY" \
  | jq -r '[.data[] | select(.sms.ready)][0].e164')
```

**3. Send the text** (an `Idempotency-Key` makes a retry safe;
[why](/platform-api/retries/#idempotency)):

```bash
MSG=$(curl -s -X POST "$OMNI_OCTO_API_URL/v1/messages" \
  -H "Authorization: Bearer $OMNI_OCTO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d "{ \"channel\": \"sms\", \"from\": \"$FROM\", \"to\": \"+14155550100\", \"text\": \"Your table is ready. Reply YES to confirm.\" }" \
  | jq -r .id)
```

The answer is `202` with `"status": "queued"` ([the full answer](#send-one-text)).

**4. Read its status.** Run it again until it is `delivered` (or `failed`,
with `error`):

```bash
curl -s "$OMNI_OCTO_API_URL/v1/messages/$MSG" -H "Authorization: Bearer $OMNI_OCTO_API_KEY"
```

```json
{ "id": "00000000-0000-4000-8000-000000000015", "object": "message", "channel": "sms",
  "from": "+16285550100", "to": "+14155550100", "status": "delivered",
  "conversation_id": "00000000-0000-4000-8000-000000000016", "campaign_id": null,
  "segments": 1, "billed_credits": 2, "error": null, "template": null,
  "text": "Your table is ready. Reply YES to confirm.", "media": [],
  "created_at": "2026-10-15T17:02:11.000Z", "sent_at": "2026-10-15T17:02:12.000Z",
  "delivered_at": "2026-10-15T17:02:14.000Z", "read_at": null,
  "updated_at": "2026-10-15T17:02:14.000Z", "metadata": null }
```

**5. Wait for the reply** as a `message.received` event (up to 25 seconds per
call; call again with the new `next_cursor` until it arrives):

```bash
curl -s "$OMNI_OCTO_API_URL/v1/events?wait=25&types=message.received&after=$CURSOR" \
  -H "Authorization: Bearer $OMNI_OCTO_API_KEY"
```

```json
{ "object": "list", "has_more": false, "next_cursor": "8146", "replay_truncated": null,
  "render_errors": [],
  "data": [
    { "id": "evt_00000000000000000000000000000001", "type": "message.received",
      "workspace_id": "00000000-0000-4000-8000-000000000001",
      "created_at": "2026-10-15T17:03:40.571Z", "seq": 8146,
      "data": { "channel": "sms", "from": "+14155550100", "to": "+16285550100",
                "body": "YES", "media": [], "media_failed": 0, "keyword": null, "quick_reply": null,
                "channel_id": "00000000-0000-4000-8000-000000000019",
                "message_id": "00000000-0000-4000-8000-000000000017",
                "conversation_id": "00000000-0000-4000-8000-000000000016",
                "contact_id": "00000000-0000-4000-8000-000000000018",
                "campaign_id": null, "received_at": "2026-10-15T17:03:40.246Z" } } ] }
```

In production, receive the same event on a [webhook](/platform-api/events/)
instead of waiting for it. The reply is also in the thread:
`GET /v1/conversations/{conversation_id}` ([Work the inbox](/platform-api/inbox/)).

## Send one text

```bash
curl -X POST https://api.omniocto.com/v1/messages \
  -H "Authorization: Bearer $OMNI_OCTO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "channel": "sms", "from": "+16285550100", "to": "+14155550100", "text": "Your table is ready" }'
```

```json
{ "id": "00000000-0000-4000-8000-000000000015", "object": "message", "channel": "sms",
  "from": "+16285550100", "to": "+14155550100", "status": "queued",
  "conversation_id": "00000000-0000-4000-8000-000000000016", "campaign_id": null,
  "segments": null, "billed_credits": null, "error": null, "template": null,
  "text": "Your table is ready", "media": [],
  "created_at": "2026-10-15T17:02:11.000Z", "sent_at": null, "delivered_at": null, "read_at": null, "updated_at": null,
  "metadata": null }
```

The answer is `202 Accepted`: the text is on its way, not yet delivered.
Follow it with [`GET /v1/messages/{id}`](#read-a-texts-status) or the
`message.status_updated` [webhook](/platform-api/events/).

The same request in Node and Python:

```js title="Node"
// One new key per text; keep it to retry this same text.
const key = crypto.randomUUID();
const res = await fetch("https://api.omniocto.com/v1/messages", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.OMNI_OCTO_API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": key,
  },
  body: JSON.stringify({
    channel: "sms",
    from: "+16285550100",
    to: "+14155550100",
    text: "Your table is ready",
  }),
});
const message = await res.json();
if (!res.ok) throw new Error(`${message.error.code}: ${message.error.message}`);
console.log(message.id, message.status);
```

```python title="Python"
import os
import uuid
import requests

key = str(uuid.uuid4())  # one new key per text; keep it to retry this same text
res = requests.post(
    "https://api.omniocto.com/v1/messages",
    headers={
        "Authorization": f"Bearer {os.environ['OMNI_OCTO_API_KEY']}",
        "Idempotency-Key": key,
    },
    json={"channel": "sms", "from": "+16285550100", "to": "+14155550100",
          "text": "Your table is ready"},
)
message = res.json()
if not res.ok:
    raise RuntimeError(f"{message['error']['code']}: {message['error']['message']}")
print(message["id"], message["status"])
```

<a id="retry-safely"></a>

- `from` is one of your numbers that is cleared to text
  ([pick one](#pick-a-from-number)).
- `to` is E.164, such as `+14155550100`
  ([phone numbers](/platform-api/overview/#phone-numbers)).
- `text` is up to 1,600 characters; `conversation: "none"` keeps it out of
  the recipient's conversation.
- `metadata` (optional): up to 20 string key/value pairs (keys 1 to 40
  characters, values up to 500), returned on the message and its events.
  `{}` is the same as none.
- **Always send an `Idempotency-Key`.** A retry with it never texts twice
  ([how retries work](/platform-api/retries/#idempotency)).
- Any other field is `400 unknown_field`, with the field you most likely
  meant as `hint`.
- Up to 10 texts a second, a burst of 50, per workspace and per key
  ([rate limits](/platform-api/retries/#rate-limits)).

## Before your first text

1. An **API key**: dashboard,
   [Settings → API Keys & Webhooks](https://agent.omniocto.com/settings/api-keys).
2. **A number that can text**: [Pick a `from` number](#pick-a-from-number).
3. That number **registered with the carriers**:
   [Get a number cleared to text](#get-a-number-cleared-to-text). It takes 1
   to 3 weeks, so start it first.
4. **Credits**: [Credits and pricing](#credits-and-pricing).

### Pick a `from` number

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

```json
{ "object": "list", "has_more": false, "next_cursor": null,
  "data": [
    { "id": "pn-0000000002", "object": "sender", "e164": "+16285550100", "label": "Front desk",
      "trial": false, "mode": "managed",
      "sms": { "ready": true, "status": "cleared", "fix": null, "provider": "twilio",
               "campaigns": { "ready": true, "status": "cleared", "fix": null } },
      "whatsapp": null,
      "voice": { "ready": true, "status": "ready", "fix": null },
      "channel_uuids": ["4e1d…", "8a07…"] }
  ] }
```

Pick a number whose `sms.ready` is `true` and pass its `e164` or its `id` as
`from`. When `ready` is `false`, `sms.fix` says what to do.
`GET /v1/senders` lists all your numbers at once, unpaged
([lists](/platform-api/overview/#lists-and-paging)), and `from`
takes any of the [`from` forms](/platform-api/overview/#ids).

- `sms` is `null` for the shared trial number, which never texts.
- `sms.status` `no_sms_channel` is a voice-only number:
  `400 from_not_sms_capable`.
- `sms.provider` is the carrier the number texts through (`twilio`, `telnyx`
  or `vonage`). Texts through the API and SMS campaigns send from Twilio
  numbers for now: a Telnyx or Vonage number reads `sms.status`
  `provider_unsupported`, and as the `from` of a text or a campaign it is
  `400 from_not_sms_capable`.
- `sms.campaigns` says whether an SMS campaign can send from the number now
  (`ready`, `status`, `fix`). Today it matches `sms`, since a campaign sends
  from the same channel as a single text.
- Any other `sms.status` is the registration state below.

### Get a number cleared to text

US carriers accept business texts only from a registered number (10DLC for a
local number, toll-free verification for a toll-free one). Until then the
number receives texts, but a send from it is `422 sender_not_cleared`, with
`sender_status` and a `hint` that matches `sms.fix`.

Registration needs a person once: in the dashboard open
[Phone numbers](https://agent.omniocto.com/phone-numbers), pick the number and
choose **Request registration**. Carriers usually take 1 to 3 weeks. We email
you when they answer, and
[Settings → Compliance → Verification requests](https://agent.omniocto.com/settings/compliance/verification-requests)
shows where each request is. Poll `GET /v1/senders` for `sms.status`:

| `sms.status` | means | can send |
|---|---|---|
| `receiving_only` | receives texts; no registration yet | no: request registration |
| `in_review` | registration submitted; carriers are reviewing it | no: wait |
| `cleared` | registered and approved | yes |
| `rejected` | carriers rejected the registration | no: fix it in the dashboard and resubmit |
| `probe_failed` | we could not read the carrier's status just now | no: we retry on our own |
| `not_receiving` | the number's incoming texts go to a Twilio Messaging Service, not to us, so replies and STOP requests never reach us | no: whoever manages that Messaging Service sets its Integration to **Defer to sender's webhook** (this changes it for every number in the service) |

A campaign also needs the
[outbound compliance acknowledgment](/platform-api/campaigns/#accept-the-outbound-compliance-acknowledgment),
once per workspace.

### Credits and pricing

A text costs credits per segment, and a send needs a card on file and a
balance that covers it: [what each text costs](/platform-api/billing/#sms-and-mms).

## Send a picture (MMS)

Upload the file with `POST /v1/media`, as a multipart `file` part. It takes
a JPEG, PNG or GIF image up to 5 MB, or an MP4, MOV or WebM video up to
50 MB. Images are sent without their metadata (such as GPS); every video is
sent as an MP4 of at most 500 KB (`"transcoded": true`). The file's bytes
decide its type, not its name or declared type.

```bash
curl -X POST https://api.omniocto.com/v1/media \
  -H "Authorization: Bearer $OMNI_OCTO_API_KEY" -F file=@menu.jpg
```

```json
{ "object": "uploaded_media",
  "media": "sms-media/00000000-0000-4000-8000-000000000002/7d0c…__menu.jpg",
  "content_type": "image/jpeg", "size": 182044, "transcoded": false,
  "preview_url": "https://…", "preview_expires_in": 3600,
  "expires_at": "2026-10-26T12:00:00.000Z" }
```

Send it within 7 days (`expires_at`), or the unused file is deleted. A
message, campaign or template that uses it keeps it. `preview_url` is a
link to view the stored file for `preview_expires_in` seconds (`null` if it
could not be made). There is no `Idempotency-Key` on uploads: a retried
upload stores a second copy, which is deleted after 7 days if unused.

Then pass its `media` as `media` (or as `content.media` on an SMS
[campaign](/platform-api/campaigns/)):

```bash
curl -X POST https://api.omniocto.com/v1/messages \
  -H "Authorization: Bearer $OMNI_OCTO_API_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "channel": "sms", "from": "+16285550100", "to": "+14155550100",
        "text": "Tonight'\''s specials", "media": "<media from the upload>" }'
```

MMS goes to +1 numbers only: the US, Canada, Puerto Rico and the other +1
regions; another `to` is `422 mms_unsupported_country`. Keep the file until the send is final: deleting it answers
`409 media_in_use` while a send still needs it.

## Use a saved SMS template

The text templates saved in the dashboard (Campaigns, Templates) are
readable with your key. `GET /v1/sms-templates` lists them, newest first
(`limit`, `after`), and `GET /v1/sms-templates/{id}` reads one by its `tp-…`
id or UUID:

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

```json
{ "object": "list", "has_more": false, "next_cursor": null,
  "data": [ { "id": "tp-0000000001", "object": "sms_template", "uuid": "5b2c…",
              "name": "Welcome", "text": "Hi {{first_name}}, your table is ready.",
              "variables": ["first_name"], "segments": 1, "encoding": "gsm7",
              "media": null, "created_at": "2026-10-01T16:20:00.000Z" } ] }
```

Use `text` as a campaign's `content.text`, which fills each
`{{placeholder}}` per recipient. A single send (`POST /v1/messages`) sends
`text` as written, so fill the placeholders in your own code first.
`segments` and `encoding` (`gsm7`, or `ucs2` when it has an emoji or another
character GSM-7 cannot carry) count the text as written. `media` describes a
saved picture or video (`kind`, `content_type`, `bytes`), not the file
itself: to send it, upload it as [Send a picture](#send-a-picture-mms) shows.
The API cannot create, edit or delete SMS templates yet.

## Read a text's status

```bash
curl https://api.omniocto.com/v1/messages/00000000-0000-4000-8000-000000000015 \
  -H "Authorization: Bearer $OMNI_OCTO_API_KEY"
```

```json
{ "id": "00000000-0000-4000-8000-000000000015", "object": "message", "channel": "sms",
  "from": "+16285550100", "to": "+14155550100", "status": "undelivered",
  "conversation_id": "00000000-0000-4000-8000-000000000016", "campaign_id": null,
  "segments": 1, "billed_credits": 2,
  "error": { "code": "30007", "category": "carrier_filtered",
             "message": "The carrier filtered the message.",
             "action": "Make sure your number is registered for business texting and the message names your business. Avoid public link shorteners." },
  "template": null, "text": "Your order 1042 is ready for pickup.", "media": [],
  "created_at": "2026-10-15T17:02:11.000Z",
  "sent_at": "2026-10-15T17:02:12.000Z", "delivered_at": null, "read_at": null,
  "updated_at": "2026-10-15T17:02:19.000Z", "metadata": { "order_id": "1042" } }
```

`text` and `media` are what was sent. In the inbox, the same text is the
conversation message whose `message_id` is this `id`.

This table is the meaning of every message `status`, on every channel:

| `status` | means |
|---|---|
| `queued` | accepted; not yet with the carrier |
| `sending` | being handed to the carrier |
| `sent` | the carrier has it; waiting for its delivery receipt |
| `delivered` | the carrier says the phone got it |
| `read` | WhatsApp only; never set for a text |
| `undelivered` | the carrier could not deliver it |
| `failed` | it did not go out, or the carrier refused it |
| `skipped` | the person opted out just before it went; nothing was sent |
| `unconfirmed` | we could not confirm it went out. It was not resent: do not resend |

Once a text has failed, `error` says why. Branch on `error.category`, a
closed set:

| `error.category` | means |
|---|---|
| `opted_out` | The number opted out (STOP), or is on your do-not-contact list. Do not resend. |
| `carrier_filtered` | The carrier blocked it as unwanted. |
| `rate_limited` | Sending too fast for the carrier; we slow down on our own. |
| `invalid_number` | Not a number that can receive texts. |
| `unreachable` | The phone could not be reached. |
| `account` | Your number or account needs attention (registration, credentials). |
| `unconfirmed` | We could not confirm it went out. It was not resent: do not resend. |
| `blocked_by_route` | Your account's sending rules refused this destination. |
| `not_sent` | It never reached the carrier; you were not charged. |
| `other` | Anything else; `message` says what we know. |

`error.code` is the carrier's own code when there is one; `message` and
`action` are our own words. Rather than poll, subscribe to
`message.status_updated` ([webhooks](/platform-api/events/)).

## List texts

```bash
# Everything you texted today, newest first
curl "https://api.omniocto.com/v1/messages?channel=sms&created_after=2026-10-16T00:00:00Z&limit=50" \
  -H "Authorization: Bearer $OMNI_OCTO_API_KEY"
# One person's texts
curl "https://api.omniocto.com/v1/messages?channel=sms&to=%2B14155550100&limit=20" \
  -H "Authorization: Bearer $OMNI_OCTO_API_KEY"
# Texts and WhatsApp messages together (leave out channel)
curl "https://api.omniocto.com/v1/messages?limit=20" \
  -H "Authorization: Bearer $OMNI_OCTO_API_KEY"
```

`GET /v1/messages` lists what you sent, newest first: with `channel=sms` your
texts, and with `channel` left out your texts and WhatsApp template messages in
one list, each item with its `channel`. Narrow it by `to`, `campaign`, `from`,
`status` or time; in a query string write `+` as `%2B`. Without `channel`,
`status=undelivered` lists texts only and `status=read` WhatsApp only. What the
person wrote and inbox replies are not listed: they arrive as
[events](/platform-api/events/) and are in
`GET /v1/conversations/{id}/messages`. A list is not a sync feed, so overlap your
window and de-duplicate by `id`.
[Every filter](/platform-api/reference/messages/#get-v1messages).

## When it fails

<a id="limits"></a>

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

| status | code | what to do |
|---|---|---|
| 422 | `sender_not_cleared` | [register the number](#get-a-number-cleared-to-text) |
| 400 | `invalid_number` | send `to` as E.164; the `hint` has the form |
| 402 | `insufficient_credits` | add credits; `short_credits` says how many, `GET /v1/balance` what you have |
| 409 | `opted_out` | the number opted out or is on your do-not-contact list: do not resend |
| 429 | `rate_limited` | retry with the same key after `Retry-After` |

Every SMS code, with its fix:
[Errors → SMS messages](/platform-api/errors/#sms-messages). Sending limits:
[Rate limits](/platform-api/retries/#rate-limits).

## Next

- [Run a campaign](/platform-api/campaigns/): text many people at once.
- [Receive webhooks](/platform-api/events/): delivery receipts and replies.
- [Test without spending](/platform-api/testing/): text yourself first.