SMS
Send a text message from one of your account's phone numbers, and find out which numbers those are.
- Base path:
https://api.account.telebroad.com/api/public/v1 - Auth header:
Authorization: Bearer ACCESS_TOKEN - Scopes:
sms:readto list your numbers,sms:sendto send
:::warning Sending costs money Every message sent through this API is billed to your account at your rate card's price. The response tells you exactly what the call cost — see Billing. There is no free allowance on the API.
Listing your numbers (sms:read) costs nothing and sends nothing.
:::
Endpoints
| Method & path | Scope | Purpose |
|---|---|---|
GET /sms/lines | sms:read | List the numbers you can send from |
POST /sms/messages | sms:send | Send a text message |
POST /sms/conversations/{line}/{number}/resolve | sms:write | Mark a conversation handled |
POST /sms/conversations/{line}/{number}/comments | sms:write | Attach an internal note |
The scopes are separate on purpose: rendering a number picker should not require the ability to spend money, closing threads should not let you text customers, and a send-only integration should not be able to enumerate your account's numbers.
Your SMS numbers
curl https://api.account.telebroad.com/api/public/v1/sms/lines \
-H "Authorization: Bearer $ACCESS_TOKEN"
{
"data": [
{
"number": "12125550188",
"name": "Acme Support",
"userIds": [481920, 481925],
"bulkEnabled": false
}
]
}
Every number in this list is one POST /sms/messages will accept as from, and
a number missing from it is one it would reject with 403. Both read the same
setting, so this list is the answer to "what can I put in from" — you never have
to discover a valid sender by trial and error.
| Field | Notes |
|---|---|
number | Already in the exact form from wants. Copy it straight through; no reformatting. |
name | The line's caller-ID name, as shown in the portal. Empty if none is set. |
userIds | The users this line is assigned to in the portal's message center, matching id in Users. |
bulkEnabled | Whether the number is registered for 10DLC bulk/campaign traffic. |
:::caution An empty userIds means everyone
"userIds": [] means the line is unrestricted — available to all users — not
that nobody has it. A client that filters on this list without special-casing empty
will hide every shared number on the account.
:::
bulkEnabled is reported, never filtered on. 10DLC campaign registration is
not required to send through this API; it applies to bulk campaigns, which this
API does not offer. A line with bulkEnabled: false sends fine here.
What you see
For an API key, the whole account's SMS-capable numbers.
For an OAuth token, only the numbers the authorizing user's role grants them — the same numbers they see in the portal. This is deliberate: a delegated app should not be able to enumerate an account's full number inventory, or hand its user a picker where most entries fail on send.
Send a message
curl -X POST https://api.account.telebroad.com/api/public/v1/sms/messages \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"from": "12125550188",
"to": ["13475550123"],
"message": "Your appointment is confirmed for Tuesday at 10am."
}'
Request
| Field | Type | Required | Notes |
|---|---|---|---|
from | string | yes | A number on your account with SMS messaging enabled. Digits only; formatting is stripped. |
to | string[] | yes | 1–20 distinct recipients. More than one creates a group thread — see below. |
message | string | yes | The text. |
from is required and never guessed. It is what the recipient sees and what the
carrier attributes your traffic to, so picking one for you would be the wrong kind
of convenience.
Unknown fields are rejected, not ignored — a typo like "mesage" gets a 400
instead of silently sending nothing.
:::caution One recipient or many changes the product and the price
to with one number sends a direct SMS, billed per segment.
to with two or more numbers creates a group thread — a single
conversation all participants can see and reply into. That is an MMS product,
billed once, regardless of body length or how many participants.
If you want N independent one-to-one messages, make N requests. Putting them
in one to array does not send N texts; it puts those people in a room together.
:::
Response
{
"data": {
"from": "12125550188",
"billing": {
"billed": true,
"currency": "USD",
"type": "sms",
"units": 1,
"totalPrice": 0.0025,
"recipientCount": 1,
"skippedCount": 0
},
"messages": [
{
"id": 88214417,
"to": ["13475550123"],
"type": "sms",
"status": "sent",
"units": 1,
"unitPrice": 0.0025,
"price": 0.0025
}
],
"skipped": []
}
}
messages[].to is always an array, including for a single recipient, so you
never have to branch on the shape.
| Status | Meaning |
|---|---|
201 | The message was sent, and charged. |
200 | Nothing was sent because every recipient was skipped. Nothing was charged. |
4xx | Rejected before anything was sent. Nothing was charged. |
5xx | Nothing was sent. Nothing was charged — guaranteed, which is what makes a retry safe. |
status: "sent" means the carrier gateway accepted the message, not that a
handset received it. Subscribe to the AccountSMS
webhook for delivery events.
Billing
Every response carries a billing object. billed is always true: it describes
the endpoint, not the individual call, so it stays true even when a particular
call cost nothing.
| Field | Meaning |
|---|---|
billed | Always true. Messages sent through this API are always priced. |
currency | USD. |
type | sms or mms — which of the two billing models applied. |
units | What you were charged for: segments for sms, always 1 for mms. |
totalPrice | What this request cost. |
recipientCount | How many destinations it went to. For mms this does not multiply the price. |
skippedCount | Recipients dropped before sending, and never charged for. |
price = unitPrice × units, always.
The two models
sms | mms (group thread) | |
|---|---|---|
| When | to has one recipient | to has two or more |
| Rate | your SMS rate | your MMS rate |
| Charged | per segment | once |
| Body length affects price | yes | no |
| Recipient count affects price | n/a | no |
So a 6-segment text to one person costs 6 × your SMS rate, while the same body to eight people in a group thread costs one MMS rate. This mirrors how bulk campaigns are billed on the platform.
Segments (sms only)
SMS is billed in segments — the unit the carrier actually charges for. How many you use depends on both length and which characters you use:
| Content | Single segment | Each segment once it splits |
|---|---|---|
| Plain GSM-7 text (Latin letters, digits, common punctuation) | up to 160 chars | 153 chars |
| Anything outside GSM-7 | up to 70 chars | 67 chars |
The encoding is chosen per message, not per character. One character outside GSM-7 anywhere in the body drops the whole message to the 70-character budget. This is the most common surprise on a bill.
Scripts and characters that always take the 70/67 path:
- Hebrew, Arabic, Cyrillic, Greek, Chinese, Japanese, Korean — any non-Latin script
- Emoji
- Curly quotes (
’“), en/em dashes (–—),•,…— these often arrive invisibly from word processors and CMS fields
Character cost within a UCS-2 message differs too:
- A Hebrew or Arabic letter costs 1 unit — so 70 Hebrew characters is one segment, 71 is two.
- Most emoji cost 2 units (they are surrogate pairs) — so 69 Hebrew letters plus one emoji is already two segments.
A few accented Latin vowels (é è à ì ò ù) and some Greek capitals are in GSM-7
and do not force the switch. €, [, ], {, }, \, ^, ~, | are in
the GSM-7 extension table and cost 2 characters each while staying on the
160/153 budget.
Hebrew example: a 100-character Hebrew message is 2 segments (100 > 70, then 100 ÷ 67 rounds up to 2), where the same 100 characters in English would be 1. Budget Hebrew and Arabic content at roughly 70 characters per segment.
There is no segment cap: a long body simply costs more segments. The only bound is 2000 bytes on the body itself (see Limits). Very long concatenated messages are more likely to be mangled or dropped by the receiving carrier, so keep marketing-length bodies short for reasons of delivery rather than of policy.
Skipped recipients
A skipped recipient is never sent to and never charged for. reason is a stable
token you can branch on:
reason | Meaning |
|---|---|
opted_out | The recipient replied STOP to this number. Required by carrier rules and not overridable. |
duplicate | The same number appeared more than once in to, after normalization. |
invalid | Not a dialable number. |
If every recipient is skipped you get 200 with an empty messages array and
totalPrice: 0.
{
"data": {
"from": "12125550188",
"billing": { "billed": true, "currency": "USD", "type": "sms", "units": 1, "totalPrice": 0, "recipientCount": 0, "skippedCount": 1 },
"messages": [],
"skipped": [{ "to": "+1 (347) 525-8144", "reason": "opted_out" }]
}
}
skipped[].to echoes what you sent, so you can match it to your own records.
messages[].to holds the normalized forms that were actually dialled.
Number normalization
Recipients are normalized to digits with a country code before anything else happens:
- Formatting is stripped:
(347) 525-8144→3475258144 - A 10-digit number gets a US/Canada
1:3475258144→13475550123 - International numbers are used as given:
442071234567
Deduplication runs after normalization, so 3475258144 and 13475550123 in
the same request are one recipient.
Conversations
A conversation is a text thread between one of your numbers and one other party. It is how the message center groups messages, and it has a resolved state your team works through — the portal's "Unresolved" filter.
:::info A conversation has no id
It is identified by both numbers: your line, and the other party. That is why
these endpoints take two path segments instead of an id. If you are reacting to an
AccountSMS webhook you already have both and can act
immediately — no lookup first.
:::
Resolve a conversation
curl -X POST https://api.account.telebroad.com/api/public/v1/sms/conversations/12125550188/13475550123/resolve \
-H "Authorization: Bearer $ACCESS_TOKEN"
No body required — a bare POST is a complete request.
{
"data": {
"line": "12125550188",
"number": "13475550123",
"resolved": true,
"resolvedBy": 481920,
"resolvedAt": "2025-08-06T14:06:41Z"
}
}
Optional body:
| Field | Type | Notes |
|---|---|---|
resolvedBy | number | Credit a specific user (an id from Users). API keys only — an OAuth token always credits its own owner. |
comment | string | Attach an internal note in the same call. Saves a round trip on the usual "closing this, here's why". |
Unknown fields are rejected, so "reslovedBy" gets a 400 rather than being
silently dropped.
Two behaviours to rely on:
- It works on threads nobody has touched. Most conversations have no stored
state until someone actions them. Resolving one creates that state rather than
returning
404— so you can close a fresh inbound thread you handled elsewhere. - It is idempotent. Resolving an already-resolved conversation returns
200with the stored state and does not re-stamp who closed it or when. Retrying after a timeout cannot rewrite history.
resolvedBy is absent when an integration resolved the thread with no person
named — an API key has no user, and a 0 there would read as a real one.
Add an internal comment
curl -X POST https://api.account.telebroad.com/api/public/v1/sms/conversations/12125550188/13475550123/comments \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"comment": "Customer confirmed the reschedule by phone. Nothing further needed."}'
Returns 201 with the conversation's current state.
:::caution "Internal" is a guarantee, not a label The note is stored where nothing in the platform can deliver it. There is no code path that sends a comment to the other party, so it cannot leak to them even by mistake. It is visible to your team in the portal.
This is not a "private" flag on a message that something else does send — it is a different store entirely. :::
Commenting does not resolve the thread. Pass comment to the resolve endpoint
if you mean both.
Conversation errors
| Status | type | Cause |
|---|---|---|
400 | invalid_request | A path segment is not a phone number, an unknown body field, or an empty comment. |
400 | invalid_request | resolvedBy is not a user on your account. |
403 | insufficient_scope | The credential lacks sms:write. |
403 | permission_denied | line is not a number your credential is authorized for. |
Formatting in the path is fine — +1 (212) 555-0188 works, and the response echoes
the normalized digits so you can see what was actually addressed.
:::note There is no "conversation resolved" webhook yet
If you need to be notified when a colleague resolves a thread in the portal, say so
— it does not exist today. The AccountSMS webhook fires on messages, not on
conversation state.
:::
Errors
Errors use the standard envelope. Branch on error.type, never on error.message
— the message is human-readable and may change.
{ "error": { "type": "invalid_request", "message": "to is required and must contain at least one recipient" } }
| Status | type | Cause |
|---|---|---|
400 | invalid_request | Missing or malformed field; a body over 2000 bytes; more than 20 recipients. |
401 | unauthenticated | Missing or invalid credential. |
403 | insufficient_scope | The credential lacks sms:send. The scope field names what to request. |
403 | permission_denied | from is not a number on your account, SMS is not enabled for it, or your credential is not authorized for it. |
422 | invalid_request | No active rate applies to a destination. Contact support to have it added to your rate card — the API will not send a message it cannot price. |
502 | internal_error | The carrier gateway rejected or did not answer. Nothing was sent or charged. |
Limits and caveats
- 20 recipients per request (participants in one group thread).
- 2000 bytes of message body. Note bytes: Hebrew, Arabic and other non-Latin scripts take 2 bytes per character in UTF-8, so the effective limit is about 1000 characters for those.
- The sender must be a number on your account with SMS enabled — the same
requirement as sending from the message center in the portal. A number that is not
gets
403. (Bulk campaigns additionally require 10DLC campaign registration; this endpoint does not.) - No idempotency key in this version. If a request times out, the message may
or may not have been sent, and retrying may send and charge twice. Retry only on
4xxand5xxresponses you actually received — those are guaranteed not to have charged you. For a request that produced no response at all, check your SMS history before retrying. - No media upload yet.
mmshere refers to how a group thread is billed, not to attaching images. Sending media is not supported on this endpoint.
Example: a group thread
Three recipients, one charge:
curl -X POST https://api.account.telebroad.com/api/public/v1/sms/messages \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"from": "12125550188",
"to": ["13475550123", "14155550123", "13475550123"],
"message": "Site visit moved to Thursday 9am. Reply here if that does not work."
}'
{
"data": {
"from": "12125550188",
"billing": {
"billed": true,
"currency": "USD",
"type": "mms",
"units": 1,
"totalPrice": 0.0034,
"recipientCount": 2,
"skippedCount": 1
},
"messages": [
{
"id": 88214419,
"to": ["13475550123", "14155550123"],
"type": "mms",
"status": "sent",
"units": 1,
"unitPrice": 0.0034,
"price": 0.0034
}
],
"skipped": [{ "to": "13475550123", "reason": "duplicate" }]
}
}
One id, because it is one thread. units: 1 and one MMS rate, however long the
body is. The repeated number was dropped rather than added to the thread twice.
If participants sit on different rates (say one US and one UK number), the highest applicable MMS rate is charged for the thread.