POST /sms/messages
Send a text message
This spends money. Every message is priced from the account's rate card and lands on its invoice. The charge is in the response — you never have to wait for an invoice to find out what a call cost.
| Scope | sms:send |
| Role permission | Phone numbers — from must be one of theirs |
| Cost | Per segment (sms) or once (mms) |
Two things make a message an MMS, and either alone is enough: more than one recipient, or any attachment. An MMS is charged once, whatever the body length or participant count.
One recipient or many changes the product and the price. One number
in to sends a direct SMS, billed per segment. Two or more creates a
group thread — one conversation all participants see and can reply
into — which is an MMS, billed once regardless of body length or
participant count.
If you want N independent one-to-one messages, make N requests.
Putting them in one to array puts those people in a room together.
Unknown fields are rejected, so a typo like "mesage" gets a 400
instead of silently sending nothing.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
from | string | yes | A number on your account with SMS enabled — one of the entries from GET /sms/lines. Digits only; formatting is stripped. Required and never guessed: it is what the recipient sees and what the carrier attributes your traffic to. |
to | string[] | yes | 1–20 distinct recipients. More than one creates a group thread, billed once as MMS — not N separate texts. |
message | string | no | The text. Required unless you send attachments — a picture with no caption is an ordinary MMS. Without attachments, a whitespace-only body is rejected rather than sent, because it would be charged for. |
attachments | object[] | no | Files to send as MMS. Up to 10, each at most 5 MB decoded. Attachments change the price. A message carrying any file is an MMS at the carrier, so it is charged once at the MMS rate rather than per segment — whether it goes to one recipient or twenty. You send the bytes, not a URL. We host the file and return its URL in the response. |
attachments[].filename | string | yes | The filename. Its extension is load-bearing — it decides the content type the recipient's handset is told, so a name without one is rejected rather than delivered as a file nothing can open. |
attachments[].contentType | image/jpeg | image/png | image/bmp | image/gif | yes | The file's MIME type. Only these four are deliverable; anything else is rejected here rather than accepted, charged at the MMS rate, and then silently not rendered by the handset. |
attachments[].content | string | yes | The file itself, standard base64, at most 5 MB decoded. Note that 5 MB is what this API accepts, not what arrives: carriers routinely transcode or drop large MMS. 1 MB or under is what delivers reliably. |
One recipient — SMS, billed per segment:
{
"from": "12125550188",
"to": [
"13475550123"
],
"message": "Your appointment is confirmed for Tuesday at 10am."
}
Three recipients — group thread, billed once as MMS:
{
"from": "12125550188",
"to": [
"13475550123",
"13475550124",
"13475550125"
],
"message": "Crew, the Tuesday job moved to 9am."
}
One recipient with a picture — MMS, billed once:
{
"from": "12125550188",
"to": [
"13475550123"
],
"message": "Here's the signed copy.",
"attachments": [
{
"filename": "receipt.png",
"contentType": "image/png",
"content": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
}
]
}
Responses
201
Sent, and charged.
| Field | Type | Description |
|---|---|---|
from | string | The number the message was sent from. |
billing | object | Present on every send response, always — including when nothing was charged. |
billing.billed | boolean | Always true. It describes the endpoint, not the individual call, so it stays true even when a particular call cost nothing. Unlike the portal, API traffic never consumes the rate card's monthly free allowance. |
billing.currency | string | The currency of every price in this response. Always USD today. |
billing.type | sms | mms | sms — one recipient and no attachments, charged per segment, so a long body costs a multiple of a short one. mms — a group thread or a message carrying attachments, charged once, regardless of body length or participant count. |
billing.units | integer | Segments for sms, 1 for mms. |
billing.totalPrice | number | What this request cost. 0 if nothing was sent. |
billing.recipientCount | integer | How many destinations it went to. For mms this does not multiply the price. |
billing.skippedCount | integer | How many supplied recipients were dropped, and so never charged for. |
messages | object[] | What was dispatched. Empty when every recipient was skipped. |
messages[].id | integer | The message id. Absent when the gateway returned none. |
messages[].to | string[] | The normalized destinations — what was actually dialled and what the charge is against. Always an array, including for one recipient, so you never branch on the shape. |
messages[].type | sms | mms | Which billing model applied. mms means the message was either a group thread or carried attachments — either one alone is enough. |
messages[].status | string | sent means the carrier gateway accepted the message — not that a handset received it. Subscribe to the AccountSMS webhook for delivery events. |
messages[].media | string[] | The hosted URLs of the attachments you sent, in the order supplied. Absent when the message had none. These URLs are publicly fetchable — unguessable, but nothing else protects them. Treat one as a secret, and don't forward it to anyone who shouldn't see the message. |
messages[].units | integer | What you were charged for — segments for sms, always 1 for mms. |
messages[].unitPrice | number | Rate per unit. On a group thread whose participants sit on different rates, this is the highest that applied. |
messages[].price | number | unitPrice × units, always. |
skipped | object[] | Recipients dropped before sending, and never charged for. |
skipped[].to | string | The recipient as you supplied it, so you can match it back to your own input — a skipped recipient may not have normalized at all. |
skipped[].reason | opted_out | duplicate | invalid | A stable token, safe to branch on. opted_out — the recipient sent STOP to this number. duplicate — the number appeared twice in to. invalid — not a dialable number. |
{
"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": []
}
}
200
Nothing was sent because every recipient was skipped (opted out, duplicate, or unparseable). Nothing was charged.
| Field | Type | Description |
|---|---|---|
from | string | The number the message was sent from. |
billing | object | Present on every send response, always — including when nothing was charged. |
billing.billed | boolean | Always true. It describes the endpoint, not the individual call, so it stays true even when a particular call cost nothing. Unlike the portal, API traffic never consumes the rate card's monthly free allowance. |
billing.currency | string | The currency of every price in this response. Always USD today. |
billing.type | sms | mms | sms — one recipient and no attachments, charged per segment, so a long body costs a multiple of a short one. mms — a group thread or a message carrying attachments, charged once, regardless of body length or participant count. |
billing.units | integer | Segments for sms, 1 for mms. |
billing.totalPrice | number | What this request cost. 0 if nothing was sent. |
billing.recipientCount | integer | How many destinations it went to. For mms this does not multiply the price. |
billing.skippedCount | integer | How many supplied recipients were dropped, and so never charged for. |
messages | object[] | What was dispatched. Empty when every recipient was skipped. |
messages[].id | integer | The message id. Absent when the gateway returned none. |
messages[].to | string[] | The normalized destinations — what was actually dialled and what the charge is against. Always an array, including for one recipient, so you never branch on the shape. |
messages[].type | sms | mms | Which billing model applied. mms means the message was either a group thread or carried attachments — either one alone is enough. |
messages[].status | string | sent means the carrier gateway accepted the message — not that a handset received it. Subscribe to the AccountSMS webhook for delivery events. |
messages[].media | string[] | The hosted URLs of the attachments you sent, in the order supplied. Absent when the message had none. These URLs are publicly fetchable — unguessable, but nothing else protects them. Treat one as a secret, and don't forward it to anyone who shouldn't see the message. |
messages[].units | integer | What you were charged for — segments for sms, always 1 for mms. |
messages[].unitPrice | number | Rate per unit. On a group thread whose participants sit on different rates, this is the highest that applied. |
messages[].price | number | unitPrice × units, always. |
skipped | object[] | Recipients dropped before sending, and never charged for. |
skipped[].to | string | The recipient as you supplied it, so you can match it back to your own input — a skipped recipient may not have normalized at all. |
skipped[].reason | opted_out | duplicate | invalid | A stable token, safe to branch on. opted_out — the recipient sent STOP to this number. duplicate — the number appeared twice in to. invalid — not a dialable number. |
{
"data": {
"from": "12125550188",
"billing": {
"billed": true,
"currency": "USD",
"type": "sms",
"units": 0,
"totalPrice": 0,
"recipientCount": 0,
"skippedCount": 1
},
"messages": [],
"skipped": [
{
"to": "13475550123",
"reason": "opted_out"
}
]
}
}
Errors
| Status | Meaning |
|---|---|
400 | invalid_request — a missing or malformed field, an unknown enum value, or an unknown field in the body. Nothing was charged. |
401 | unauthenticated — no credential, or one that is invalid, revoked or expired. The WWW-Authenticate header names the scope the endpoint wanted. |
403 | Two different failures share this status, and the type tells them apart: |
422 | invalid_request — no active rate applies to one of the destinations. This API will not send a message it cannot price. |
500 | internal_error — something failed on our side. For a send, nothing was charged, guaranteed, which is what makes a retry safe. |
See Errors for the full catalog and what to do about each.
Example
curl -X POST 'https://api.account.telebroad.com/api/public/v1/sms/messages' \
-H "Authorization: Bearer $TB_KEY" \
-H 'Content-Type: application/json' \
-d '{"from":"12125550188","to":["13475550123"],"message":"Your appointment is confirmed for Tuesday at 10am."}'