Skip to content

Send a text

Download .md

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, a number cleared to text, and credits. Every field: reference.

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

Terminal window
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:

Terminal window
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):

Terminal window
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):

Terminal window
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).

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

Terminal window
curl -s "$OMNI_OCTO_API_URL/v1/messages/$MSG" -H "Authorization: Bearer $OMNI_OCTO_API_KEY"
{ "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):

Terminal window
curl -s "$OMNI_OCTO_API_URL/v1/events?wait=25&types=message.received&after=$CURSOR" \
-H "Authorization: Bearer $OMNI_OCTO_API_KEY"
{ "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 instead of waiting for it. The reply is also in the thread: GET /v1/conversations/{conversation_id} (Work the inbox).

Terminal window
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" }'
{ "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} or the message.status_updated webhook.

The same request in Node and Python:

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
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"])

  • from is one of your numbers that is cleared to text (pick one).
  • to is E.164, such as +14155550100 (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).
  • 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).
  1. An API key: dashboard, Settings → API Keys & Webhooks.
  2. A number that can text: Pick a from number.
  3. That number registered with the carriers: Get a number cleared to text. It takes 1 to 3 weeks, so start it first.
  4. Credits: Credits and pricing.
Terminal window
curl https://api.omniocto.com/v1/senders -H "Authorization: Bearer $OMNI_OCTO_API_KEY"
{ "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), and from takes any of the from forms.

  • 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.

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, 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 shows where each request is. Poll GET /v1/senders for sms.status:

sms.statusmeanscan send
receiving_onlyreceives texts; no registration yetno: request registration
in_reviewregistration submitted; carriers are reviewing itno: wait
clearedregistered and approvedyes
rejectedcarriers rejected the registrationno: fix it in the dashboard and resubmit
probe_failedwe could not read the carrier’s status just nowno: we retry on our own
not_receivingthe number’s incoming texts go to a Twilio Messaging Service, not to us, so replies and STOP requests never reach usno: 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, once per workspace.

A text costs credits per segment, and a send needs a card on file and a balance that covers it: what each text costs.

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.

Terminal window
curl -X POST https://api.omniocto.com/v1/media \
-H "Authorization: Bearer $OMNI_OCTO_API_KEY" -F file=@menu.jpg
{ "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):

Terminal window
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.

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:

Terminal window
curl https://api.omniocto.com/v1/sms-templates -H "Authorization: Bearer $OMNI_OCTO_API_KEY"
{ "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 shows. The API cannot create, edit or delete SMS templates yet.

Terminal window
curl https://api.omniocto.com/v1/messages/00000000-0000-4000-8000-000000000015 \
-H "Authorization: Bearer $OMNI_OCTO_API_KEY"
{ "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:

statusmeans
queuedaccepted; not yet with the carrier
sendingbeing handed to the carrier
sentthe carrier has it; waiting for its delivery receipt
deliveredthe carrier says the phone got it
readWhatsApp only; never set for a text
undeliveredthe carrier could not deliver it
failedit did not go out, or the carrier refused it
skippedthe person opted out just before it went; nothing was sent
unconfirmedwe 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.categorymeans
opted_outThe number opted out (STOP), or is on your do-not-contact list. Do not resend.
carrier_filteredThe carrier blocked it as unwanted.
rate_limitedSending too fast for the carrier; we slow down on our own.
invalid_numberNot a number that can receive texts.
unreachableThe phone could not be reached.
accountYour number or account needs attention (registration, credentials).
unconfirmedWe could not confirm it went out. It was not resent: do not resend.
blocked_by_routeYour account’s sending rules refused this destination.
not_sentIt never reached the carrier; you were not charged.
otherAnything 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).

Terminal window
# 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 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.

Common codes (how errors work):

statuscodewhat to do
422sender_not_clearedregister the number
400invalid_numbersend to as E.164; the hint has the form
402insufficient_creditsadd credits; short_credits says how many, GET /v1/balance what you have
409opted_outthe number opted out or is on your do-not-contact list: do not resend
429rate_limitedretry with the same key after Retry-After

Every SMS code, with its fix: Errors → SMS messages. Sending limits: Rate limits.