Messages
One endpoint to send, two to read.
| Method | Path | Scope |
|---|---|---|
POST | /v1/messages | messages:send |
GET | /v1/messages | messages:read |
GET | /v1/messages/{id} | messages:read |
Sending
| Field | Required | Notes |
|---|---|---|
to | yes | E.164, e.g. +447400123456. A UK number given as 07… is assumed to be UK; anything else needs its country code. |
body | yes | Up to 1600 characters. See segments for what that costs. |
from | no | A sender you have. Omit it and we pick one that is valid for the destination — which is usually the right answer, because the rules differ by country. |
class | no | otp, transactional (default) or marketing. This is not cosmetic — see below. |
metadata | no | Your own key/values, returned on the message and on every webhook about it. Put your order id here rather than trying to parse it back out of the body. |
Class changes behaviour, not just reporting
| Class | Consent | Quiet hours | Route |
|---|---|---|---|
otp | Not required | Ignored | Premium — deliverability matters more than price |
transactional | Not required | Ignored | Premium |
marketing | Required | Respected | Cost-optimised |
Marketing is the only class that needs recorded prior opt-in, because under PECR it is the only one that does. Sending marketing to a contact with no recorded opt-in is refused withOPTED_OUT, and quiet hours will hold it rather than deliver it at 6am.
Do not label marketing as transactional to get past the consent check. It will send, and it will be unlawful, and the complaint arrives at your door rather than ours. The class is a declaration about the message, not a routing hint.
The response
{
"id": "01j8xk2p7n4qc3m9v0wz5bt6ay",
"object": "message",
"status": "queued",
"direction": "outbound",
"to": "+447400123456",
"to_country": "GB",
"from": "TEXTMAST",
"body": "Your code is 123456",
"encoding": "gsm7",
"segments": 1,
"class": "otp",
"price": { "amount": "0.0350", "currency": "GBP" },
"failure": null,
"scheduled_at": null,
"submitted_at": null,
"delivered_at": null,
"metadata": {},
"created_at": "2026-09-17T09:14:22+00:00"
}Fields worth a second look
price.amountis a decimal string, never a number. Parse it as a decimal. A million rows summed as doubles will not reconcile against our ledger, and finding out why takes an afternoon.encodingisgsm7orucs2. If it saysucs2and you did not expect it, something in your body is outside the GSM alphabet — often a curly apostrophe pasted from a word processor.failureis our normalised code, not the carrier's string. That is what makes it something you can branch on. See delivery reports.submitted_atis when a carrier accepted it;delivered_atis when a handset confirmed it. They are usually seconds apart and occasionally hours.
Listing
GET /v1/messages?limit=50&status=failedCursor paginated — see pagination. Not offsets: the underlying table is being written to constantly, and page=2 of a moving list silently skips rows.