Send a text
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.
Recipe: text someone and read the reply
Section titled “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):
export OMNI_OCTO_API_URL=https://api.omniocto.comexport OMNI_OCTO_API_KEY=sk-octo_...1. Mark where the event log is now, so step 5 sees only what happens after:
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):
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):
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):
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):
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).
Send one text
Section titled “Send one text”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:
// 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);import osimport uuidimport requests
key = str(uuid.uuid4()) # one new key per text; keep it to retry this same textres = 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"])fromis one of your numbers that is cleared to text (pick one).tois E.164, such as+14155550100(phone numbers).textis 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 ashint. - Up to 10 texts a second, a burst of 50, per workspace and per key (rate limits).
Before your first text
Section titled “Before your first text”- An API key: dashboard, Settings → API Keys & Webhooks.
- A number that can text: Pick a
fromnumber. - That number registered with the carriers: Get a number cleared to text. It takes 1 to 3 weeks, so start it first.
- Credits: Credits and pricing.
Pick a from number
Section titled “Pick a from number”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.
smsisnullfor the shared trial number, which never texts.sms.statusno_sms_channelis a voice-only number:400 from_not_sms_capable.sms.provideris the carrier the number texts through (twilio,telnyxorvonage). Texts through the API and SMS campaigns send from Twilio numbers for now: a Telnyx or Vonage number readssms.statusprovider_unsupported, and as thefromof a text or a campaign it is400 from_not_sms_capable.sms.campaignssays whether an SMS campaign can send from the number now (ready,status,fix). Today it matchessms, since a campaign sends from the same channel as a single text.- Any other
sms.statusis the registration state below.
Get a number cleared to text
Section titled “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, 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.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, once per workspace.
Credits and pricing
Section titled “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.
Send a picture (MMS)
Section titled “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.
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):
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
Section titled “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:
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.
Read a text’s status
Section titled “Read a text’s status”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:
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).
List texts
Section titled “List texts”# Everything you texted today, newest firstcurl "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 textscurl "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.
When it fails
Section titled “When it fails”Common codes (how errors work):
| status | code | what to do |
|---|---|---|
| 422 | sender_not_cleared | register the number |
| 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. Sending limits: Rate limits.
- Run a campaign: text many people at once.
- Receive webhooks: delivery receipts and replies.
- Test without spending: text yourself first.