Skip to main content

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:read to list your numbers, sms:send to 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 & pathScopePurpose
GET /sms/linessms:readList the numbers you can send from
POST /sms/messagessms:sendSend a text message
POST /sms/conversations/{line}/{number}/resolvesms:writeMark a conversation handled
POST /sms/conversations/{line}/{number}/commentssms:writeAttach 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.

FieldNotes
numberAlready in the exact form from wants. Copy it straight through; no reformatting.
nameThe line's caller-ID name, as shown in the portal. Empty if none is set.
userIdsThe users this line is assigned to in the portal's message center, matching id in Users.
bulkEnabledWhether 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

FieldTypeRequiredNotes
fromstringyesA number on your account with SMS messaging enabled. Digits only; formatting is stripped.
tostring[]yes1–20 distinct recipients. More than one creates a group thread — see below.
messagestringyesThe 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.

StatusMeaning
201The message was sent, and charged.
200Nothing was sent because every recipient was skipped. Nothing was charged.
4xxRejected before anything was sent. Nothing was charged.
5xxNothing 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.

FieldMeaning
billedAlways true. Messages sent through this API are always priced.
currencyUSD.
typesms or mms — which of the two billing models applied.
unitsWhat you were charged for: segments for sms, always 1 for mms.
totalPriceWhat this request cost.
recipientCountHow many destinations it went to. For mms this does not multiply the price.
skippedCountRecipients dropped before sending, and never charged for.

price = unitPrice × units, always.

The two models

smsmms (group thread)
Whento has one recipientto has two or more
Rateyour SMS rateyour MMS rate
Chargedper segmentonce
Body length affects priceyesno
Recipient count affects pricen/ano

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:

ContentSingle segmentEach segment once it splits
Plain GSM-7 text (Latin letters, digits, common punctuation)up to 160 chars153 chars
Anything outside GSM-7up to 70 chars67 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:

reasonMeaning
opted_outThe recipient replied STOP to this number. Required by carrier rules and not overridable.
duplicateThe same number appeared more than once in to, after normalization.
invalidNot 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-81443475258144
  • A 10-digit number gets a US/Canada 1: 347525814413475550123
  • 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:

FieldTypeNotes
resolvedBynumberCredit a specific user (an id from Users). API keys only — an OAuth token always credits its own owner.
commentstringAttach 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 200 with 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

StatustypeCause
400invalid_requestA path segment is not a phone number, an unknown body field, or an empty comment.
400invalid_requestresolvedBy is not a user on your account.
403insufficient_scopeThe credential lacks sms:write.
403permission_deniedline 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" } }
StatustypeCause
400invalid_requestMissing or malformed field; a body over 2000 bytes; more than 20 recipients.
401unauthenticatedMissing or invalid credential.
403insufficient_scopeThe credential lacks sms:send. The scope field names what to request.
403permission_deniedfrom is not a number on your account, SMS is not enabled for it, or your credential is not authorized for it.
422invalid_requestNo 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.
502internal_errorThe 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 4xx and 5xx responses 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. mms here 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.