Developers · Messaging API
Sonaline Messaging API
Last updated 6 November 2026
Sonaline's Messaging API is a copy of Bandwidth's Messaging API v2: the same paths, the same authentication, the same request and response bodies, the same error format and the same callbacks. If you have written against Bandwidth before, your code should work against Sonaline once you change the base URL and the credentials.
Sonaline is the provider in the middle. You talk to Sonaline, and Sonaline sends your messages on a carrier: its own carrier account, or a carrier account your workspace connected itself. So a few things work differently from Bandwidth, or are not offered at all. Each one is marked Sonaline: where it comes up, and they are all listed together in Where Sonaline differs from Bandwidth.
This guide covers the Messaging API only: sending, listing, media and callbacks. Voice, number ordering, 10DLC campaign management and number lookup are not part of it.
Base URL and your account
| Bandwidth | Sonaline | |
|---|---|---|
| Messaging API | https://messaging.bandwidth.com/api/v2 | https://api.sonaline.com/api/v2 |
| Token URL | https://api.bandwidth.com/api/v1/oauth2/token | https://api.sonaline.com/api/v1/oauth2/token |
You need:
- your account ID: a 7-digit number, the
{accountId}in every path; - a client ID and client secret: your API credentials;
- an application ID for each messaging application: the
applicationIdyou send with, and where your callbacks go.
Sonaline: a workspace admin makes these themselves, in the admin center (sonaline.app/manage) under Advanced › API & webhooks, API credentials and API applications: make, rotate or remove credentials, and add, change or remove an application. Your Sonaline provider can do the same for you from their console. Either way a new secret is shown only once, when it is made or rotated, and is not kept: if you lose it, rotate it. A workspace connected through a partner makes its own API credentials and applications here too; the partner keeps the API's on/off switch.
A workspace has at most 5 API credentials and 10 applications; one more answers 409. The account ID is given when the first credentials or the first application are made.
In the examples below, the account ID is 1234567 and the shell has:
shell
BASE=https://api.sonaline.com/api/v2
ACCOUNT=1234567
CLIENT_ID=... # from Sonaline
CLIENT_SECRET=... # from Sonaline; keep it out of source control and logs
Authentication
Two methods are accepted, as on Bandwidth. Use the first one.
OAuth 2.0 client credentials (recommended)
Ask the token URL for a bearer token. Send your client ID and secret as HTTP Basic, and the form body grant_type=client_credentials:
curl
curl -s -X POST https://api.sonaline.com/api/v1/oauth2/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data grant_type=client_credentials
{
"access_token": "sl_…",
"access_token_id": "4f1c…",
"token_type": "Bearer",
"expires_in": 3600
}
Send the token on every API call as Authorization: Bearer <access_token>. It lasts one hour. Keep it and reuse it, and get a new one shortly before expires_in runs out, as Bandwidth asks.
curl
TOKEN=$(curl -s -X POST https://api.sonaline.com/api/v1/oauth2/token \
-u "$CLIENT_ID:$CLIENT_SECRET" --data grant_type=client_credentials | jq -r .access_token)
Token errors use the OAuth 2.0 format (RFC 6749):
401 {"error":"invalid_client", …}: the client ID or secret is wrong.400 {"error":"unsupported_grant_type", …}: the grant type is notclient_credentials.
Sonaline:
- The token is an opaque string, not a JWT. Do not decode it.
- Rotating your secret, or removing the credentials, ends every token made with them at once.
client_idandclient_secretmay also be sent in the form body instead of Basic (RFC 6749 §2.3.1). Basic is preferred.
HTTP Basic
Every Messaging API call also takes Authorization: Basic base64(client_id:client_secret) directly (curl -u "$CLIENT_ID:$CLIENT_SECRET").
Sonaline: on Bandwidth, Basic means its legacy API user and password, which Bandwidth is retiring on 2 December 2026. On Sonaline, Basic takes the same client ID and secret as the token door, and it is not being retired. Even so, new code should use tokens: then the secret is sent once an hour, not on every call.
Which account
{accountId} in the path must be your own account ID. Any other ID is refused with Bandwidth's 403 {"type":"forbidden","description":"Access Denied"}, even when it is another real account.
If your API is switched off, every Messaging API call answers 403 with a description saying so, and no callbacks are sent (the token URL still gives tokens). Missing or wrong credentials answer 401 {"type":"unauthorized","description":"Authentication Failed"}. A credential needs the messaging scope for this API (every credential made before scopes existed has it); one without it answers 403 (the Calls API says what the other scopes are for). Your old x-api-key does not open this API. The other way round does work: the same Bearer token, or Basic with your client ID and secret, also reads your usage and your numbers on the older API (see Moving from the YBPBX API).
Send a message
POST /api/v2/users/{accountId}/messages
curl
curl -s -X POST "$BASE/users/$ACCOUNT/messages" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"applicationId": "93de2206-9669-4e07-948d-329f4b722ee2",
"to": ["+12025550123"],
"from": "+12025550100",
"text": "Your appointment is tomorrow at 10:00",
"tag": "appointment-7781",
"priority": "default",
"expiration": "2026-11-02T10:00:00Z"
}'
| Field | Required? | Meaning |
|---|---|---|
applicationId | Yes | One of your applications. Status callbacks for this message go to its callback URL. |
to | Yes | An array of E.164 numbers. More than one makes one group MMS between them all. No repeats. |
from | Yes | One of your account's numbers, in E.164. |
text | text, media or both | At most 2048 characters. |
media | See text | An array of URLs, each at most 4096 characters. Use a public https (or http) URL, or the URL of a file you uploaded to Sonaline (see Media). |
tag | No | Your own string, at most 1024 characters. It comes back on this message's status callbacks. |
priority | No | default or high. |
expiration | No | An RFC 3339 date-time in the future, for example 2021-03-14T01:59:26Z. |
The answer is 202 Accepted with Bandwidth's message object:
{
"id": "1589228074636lm4k2je7j7jklbn2",
"owner": "+12025550100",
"applicationId": "93de2206-9669-4e07-948d-329f4b722ee2",
"time": "2026-11-01T18:42:35.876Z",
"segmentCount": 1,
"direction": "out",
"to": ["+12025550123"],
"from": "+12025550100",
"text": "Your appointment is tomorrow at 10:00",
"tag": "appointment-7781",
"priority": "default",
"expiration": "2026-11-02T10:00:00Z"
}
Keep id: it is the message ID on every callback about this message and in the list. 202 means accepted for sending, not delivered. Delivery is reported by callback.
The same send in Node.js, getting and reusing a token:
Node.js
// Node.js 18 or later: fetch is built in.
const BASE = "https://api.sonaline.com";
const ACCOUNT = process.env.SONALINE_ACCOUNT_ID; // for example 1234567
const CLIENT_ID = process.env.SONALINE_CLIENT_ID;
const CLIENT_SECRET = process.env.SONALINE_CLIENT_SECRET;
let cached = { token: "", until: 0 };
async function accessToken() {
// Reuse the token until a minute before it runs out.
if (cached.token && Date.now() < cached.until) return cached.token;
const basic = Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64");
const resp = await fetch(`${BASE}/api/v1/oauth2/token`, {
method: "POST",
headers: {
Authorization: `Basic ${basic}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: "grant_type=client_credentials",
});
if (!resp.ok) throw new Error(`token: ${resp.status} ${await resp.text()}`);
const data = await resp.json();
cached = { token: data.access_token, until: Date.now() + (data.expires_in - 60) * 1000 };
return cached.token;
}
async function sendText(to, text) {
const resp = await fetch(`${BASE}/api/v2/users/${ACCOUNT}/messages`, {
method: "POST",
headers: {
Authorization: `Bearer ${await accessToken()}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
applicationId: process.env.SONALINE_APPLICATION_ID,
from: "+12025550100",
to: [to],
text,
}),
});
const body = await resp.json();
// 504: the carrier did not answer in time. The message may have gone,
// so do not send it again automatically.
if (resp.status !== 202) throw new Error(`${resp.status} ${body.type}: ${body.description}`);
return body.id;
}
sendText("+12025550123", "Your appointment is tomorrow at 10:00").then(console.log);
And in Python:
Python
# Python 3 with requests (pip install requests).
import os, time, requests
BASE = "https://api.sonaline.com"
ACCOUNT = os.environ["SONALINE_ACCOUNT_ID"] # for example 1234567
AUTH = (os.environ["SONALINE_CLIENT_ID"], os.environ["SONALINE_CLIENT_SECRET"])
_cached = {"token": "", "until": 0.0}
def access_token():
# Reuse the token until a minute before it runs out.
if _cached["token"] and time.time() < _cached["until"]:
return _cached["token"]
resp = requests.post(f"{BASE}/api/v1/oauth2/token", auth=AUTH,
data={"grant_type": "client_credentials"}, timeout=10)
resp.raise_for_status()
data = resp.json()
_cached.update(token=data["access_token"], until=time.time() + data["expires_in"] - 60)
return _cached["token"]
def send_text(to, text):
resp = requests.post(
f"{BASE}/api/v2/users/{ACCOUNT}/messages",
headers={"Authorization": f"Bearer {access_token()}"},
json={
"applicationId": os.environ["SONALINE_APPLICATION_ID"],
"from": "+12025550100",
"to": [to],
"text": text,
},
timeout=30,
)
body = resp.json()
# 504: the carrier did not answer in time. The message may have gone,
# so do not send it again automatically.
if resp.status_code != 202:
raise RuntimeError(f"{resp.status_code} {body['type']}: {body['description']}")
return body["id"]
print(send_text("+12025550123", "Your appointment is tomorrow at 10:00"))
Sonaline:
frommust be one of the numbers Sonaline holds for your account. Alphanumeric sender IDs are not offered.applicationIdnames one of your Sonaline applications. It is not a Bandwidth application ID, and Sonaline sends on its own.priorityandexpirationare passed to the carrier for numbers on Bandwidth. On numbers on another carrier, they are accepted and stored but have no effect.priorityandexpirationare likewise not passed on when your workspace texts through another provider.- Each carrier has its own limits, and a send past them is refused with 400 (
fieldErrorssays which). For example, a number on Skyetel takes one recipient and at most 1024 characters per message, and some carriers take no group or no media. Bandwidth takes the API's own limits above. Ask Sonaline which carrier your numbers are on. - If the carrier refuses the message outright, or cannot be reached, the answer is
500 {"type":"internal-server-error", …}with the carrier's reason indescription. - If the carrier does not answer in time, the answer is
504 {"type":"gateway-timeout", …}. The message may have gone: do not send it again automatically. It is kept in the list with status FAILED, under an ID of its own (you were not given one), and the Sonaline inbox shows it as "may have been sent".
List and search messages
GET /api/v2/users/{accountId}/messages
curl
curl -s "$BASE/users/$ACCOUNT/messages?messageDirection=INBOUND&fromDateTime=2026-11-01T00:00:00Z&limit=50" \
-H "Authorization: Bearer $TOKEN"
{
"totalCount": 2,
"pageInfo": {
"nextPage": "/api/v2/users/1234567/messages?messageDirection=INBOUND&…&pageToken=bzo1MA%3D%3D",
"nextPageToken": "bzo1MA=="
},
"messages": [
{
"messageId": "14762070468292kw2fuqty55yp2b2",
"accountId": "1234567",
"sourceTn": "+12025550123",
"destinationTn": "+12025550100",
"messageStatus": "RECEIVED",
"messageDirection": "INBOUND",
"messageType": "sms",
"segmentCount": 1,
"errorCode": 0,
"receiveTime": "2026-11-01T09:15:02.118Z",
"carrierName": "TMobile",
"messageSize": 61,
"messageLength": 42,
"attachmentCount": 0,
"recipientCount": 1,
"campaignClass": null,
"campaignId": null,
"bwLatency": 1,
"carrierLatency": null,
"callingNumberCountryA3": "USA",
"calledNumberCountryA3": "USA",
"product": "P2P",
"location": "123ABC"
}
]
}
As on Bandwidth, the list holds no message text. Include at least one query parameter.
| Parameter | Meaning |
|---|---|
messageId | One message. |
sourceTn, destinationTn | A full E.164 number, up to 10 of them comma-separated, or one partial number of 5 characters or more (URL-encode the + as %2B), matched from the start. |
messageStatus | RECEIVED, QUEUED, SENDING, SENT, FAILED, DELIVERED, ACCEPTED or UNDELIVERED. |
messageDirection | INBOUND or OUTBOUND. |
messageType | sms or mms. |
errorCode | A failure code, for example 4750. |
fromDateTime, toDateTime | ISO 8601. Compared with receiveTime. |
campaignId | Your 10DLC campaign ID. |
fromSegmentCount, toSegmentCount | A range of segment counts. |
carrierName | The carrier, for example Verizon (URL-encode AT&T as AT%26T). |
fromBwLatency, fromCarrierLatency | The least Bandwidth or carrier latency, in seconds (Advanced Quality Metrics; carrier latency is for OUTBOUND only). |
bwQueued, carrierQueued | true or false: queued in Bandwidth's network, or the carrier's (carrierQueued is for OUTBOUND only). |
product | LOCAL_A2P, P2P, SHORT_CODE_REACH, TOLL_FREE, HOSTED_SHORT_CODE, ALPHA_NUMERIC, RBM_MEDIA, RBM_RICH or RBM_CONVERSATIONAL. |
location | The location ID. |
callingNumberCountryA3, calledNumberCountryA3 | A three-letter country code, for example USA. |
fromMessageSize, toMessageSize | A range of message sizes. |
sort | Any field of a message in the list, then :asc or :desc, for example sourceTn:desc or carrierName:asc. Default receiveTime:desc; with no direction, :asc. |
limit | Page size. Default 100. |
pageToken | From pageInfo.nextPageToken or prevPageToken. Pass the same filters again. |
limitTotalCount | true caps totalCount at 10,000. |
As on Bandwidth, limit plus the rows before the page may not pass 10,000. Narrow the filters to reach older rows.
Sonaline:
- The list is read from Sonaline's own message history, the same one the Sonaline inbox shows. It covers everything kept for your account, including messages sent from the inbox or the older API, not only what this API sent. Messages someone deleted in the inbox are left out.
- Bandwidth searches a 14-day window. Sonaline searches everything it keeps for you, which is set by your retention period.
- Bandwidth's Insights fields (
carrierName,messageSize,bwLatency,carrierLatency,callingNumberCountryA3,calledNumberCountryA3,product,locationandcampaignClass) are Bandwidth's own values. Sonaline asks Bandwidth for them, matched by message ID, and keeps them, so a second list doesn't ask again. They arenullfor a message older than Bandwidth's 14-day window, for one on a number that isn't on Bandwidth, for a workspace that texts through another provider or on its own Bandwidth account, and for a message Bandwidth hasn't listed yet. A value Bandwidth doesn't have isnulltoo: carrier latency is for outbound messages only, and it has no carrier name for most MMS. - Filters on those fields (
carrierName,fromBwLatency,bwQueued,product,location,carrierQueued,fromCarrierLatency,callingNumberCountryA3,calledNumberCountryA3,fromMessageSize,toMessageSize) are answered by Bandwidth, for your numbers on Bandwidth over its 14 days. The other filters,totalCountand paging then work on that answer as usual. So these filters find nothing older than 14 days, and nothing on a number that isn't on Bandwidth. - If Bandwidth can't be asked (it errors, or takes more than a few seconds), the list still answers from what Sonaline has kept. Fields not kept yet are
null, the filters match on the kept values, andbwQueuedandcarrierQueuedmatch nothing, because they aren't values Bandwidth lists. Ask again later for the rest. - One list asks Bandwidth at most 20 pages of 1000 records. If a filter's answer from Bandwidth is longer than that (more than about 10,000 messages on your numbers in the range), Sonaline does not give you the part it got as if it were all: the list answers from the kept values, exactly as when Bandwidth can't be asked. To have Bandwidth answer the filter, narrow
fromDateTime/toDateTime. When filling in the fields of a page, a message Bandwidth wasn't reached for in time isnullthis time and asked about again on the next list. - Calls to Bandwidth are rate limited, per account (60 a minute) and for every account together, since they share Sonaline's Bandwidth account. Past either limit the list still answers at once, from the kept values, as above. A CRM that polls a filtered list every second will see this; poll less often, or list without Insights filters.
- Sorting on an Insights field orders by the values Sonaline has kept. Rows with no value (
null) come first in:ascorder. - An unknown parameter answers 400.
- Statuses: Sonaline keeps RECEIVED, SENT, DELIVERED and FAILED. A filter on QUEUED, SENDING, ACCEPTED or UNDELIVERED finds nothing.
- A group MMS you sent is one row, and its
destinationTnholds every recipient, comma-separated (Bandwidth gives one row per recipient).recipientCountsays how many. AdestinationTnfilter on any member finds it. A group MMS you received is one row too, with your number asdestinationTnand arecipientCountof 1. campaignIdis the account's campaign on every row, when Sonaline has recorded one.pageTokenis an offset, so a page boundary can move if new messages arrive between pages. SortreceiveTime:ascwith afromDateTimefor a stable walk.
Media
Sonaline stores media itself, in its own archive. Your files never sit in Bandwidth's store under your name, and every media URL you are given is on api.sonaline.com.
Upload
PUT /api/v2/users/{accountId}/media/{mediaId}. The body is the raw file and Content-Type is its type. Answer: 204 No Content.
curl
curl -s -X PUT "$BASE/users/$ACCOUNT/media/invoice-7781.pdf" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/pdf' \
--data-binary @invoice-7781.pdf
Then send it by its URL:
"media": ["https://api.sonaline.com/api/v2/users/1234567/media/invoice-7781.pdf"]
A Cache-Control header on the upload is stored and returned on download, as on Bandwidth. As on Bandwidth, use a new name for each file: uploading to the same name replaces the file.
Download
GET /api/v2/users/{accountId}/media/{mediaId} returns the bytes, with their Content-Type.
curl
curl -s "$BASE/users/$ACCOUNT/media/invoice-7781.pdf" -H "Authorization: Bearer $TOKEN" -o invoice.pdf
The same works for every media URL in a callback, for example …/media/14762070468292kw2fuqty55yp2b2/0/photo.jpg: pictures, voice notes and videos your customers send.
Delete
DELETE /api/v2/users/{accountId}/media/{mediaId} answers 204, or 404 when there is no such file.
List
GET /api/v2/users/{accountId}/media returns up to 1000 of your uploads:
[
{
"content": "https://api.sonaline.com/api/v2/users/1234567/media/invoice-7781.pdf",
"contentLength": 48213,
"mediaName": "invoice-7781.pdf"
}
]
If there are more, the answer has a Continuation-Token header. Send it back as a Continuation-Token request header for the next page.
Sonaline:
- A
mediaIdyou upload may use only letters, digits,.,_and-, at most 200 characters, and must start with a letter or a digit. Slashes are not allowed in uploads. That form is kept for a message's own parts,<messageId>/<n>/<name>. - The most one file may weigh is 3,750,000 bytes; a bigger one answers 400. An MMS layout document (SMIL) is refused with 415: send the picture itself.
- Your account keeps at most 1,000 uploaded files and 1 GB of them at once. An upload past either answers
409with{"type": "conflict", "description": …}; delete an upload you no longer need and try again. Uploading again under amediaIdyou already have replaces that file and does not count twice. Ask Sonaline if your account needs more. - Uploads are deleted 30 days after they were made (uploading again under the same
mediaIdstarts the 30 days over). They are for sending, not storage: a message you sent keeps its own copy of the file, so it stays in the Sonaline inbox for as long as your retention keeps the message. - The list shows your uploads only. A message's own attachments are not listed, and cannot be deleted (403): they stay with the message in the Sonaline inbox, for as long as your retention keeps it.
- Received attachments are kept in Sonaline's archive, so they do not expire after a few days the way they do in Bandwidth's store.
- You can only send media from Sonaline's API if it is your own. Another account's media URL is refused with 400.
Callbacks
Each application has a callback URL, and optionally a callback user name and password, set where the application is made (see Base URL and your account). Callbacks are POSTed there with Content-Type: application/json. As on Bandwidth, the body is always a JSON array of events, even when it holds only one.
Which application hears what
- An inbound text goes to the application that lists the receiving number. If none lists it, it goes to your application that lists no numbers at all. If that application has no callback URL, nothing is sent. Sonaline: on Bandwidth, numbers join an application through locations. On Sonaline, each application lists the numbers it takes, from your workspace's own numbers.
- A text from a number your workspace has blocked has no callback.
- Status callbacks (sending, sent, delivered, failed) go to the application named in the send's
applicationId. Sonaline: only messages sent through this API have status callbacks. A message sent from the Sonaline inbox, or through the older/api/sms, has none.
Event types
type | Direction | Meaning |
|---|---|---|
message-received | inbound | A customer texted one of your numbers. |
message-sending | outbound | The carrier is sending the message, typically sent for MMS. |
message-sent | outbound | The message has left for the recipient's carrier. |
message-delivered | outbound | The recipient's handset has it. |
message-failed | outbound | It could not be delivered. errorCode and description say why. |
Bodies
An inbound text with a picture:
[
{
"type": "message-received",
"time": "2026-11-01T15:43:35.502Z",
"description": "Incoming message received",
"to": "+12025550100",
"message": {
"id": "14762070468292kw2fuqty55yp2b2",
"owner": "+12025550100",
"applicationId": "93de2206-9669-4e07-948d-329f4b722ee2",
"time": "2026-11-01T15:43:34.000Z",
"segmentCount": 1,
"direction": "in",
"to": ["+12025550100"],
"from": "+12025550123",
"text": "Here is the photo",
"media": [
"https://api.sonaline.com/api/v2/users/1234567/media/14762070468292kw2fuqty55yp2b2/0/photo.jpg"
]
}
}
]
A failure:
[
{
"type": "message-failed",
"time": "2026-11-01T16:51:27.704Z",
"description": "destination-rejected-message",
"to": "+12025550123",
"errorCode": 4750,
"message": {
"id": "1589228074636lm4k2je7j7jklbn2",
"owner": "+12025550100",
"applicationId": "93de2206-9669-4e07-948d-329f4b722ee2",
"time": "2026-11-01T16:51:20.000Z",
"segmentCount": 1,
"direction": "out",
"to": ["+12025550123"],
"from": "+12025550100",
"text": "",
"tag": "appointment-7781"
}
}
]
In an inbound callback, to is your number. In a status callback, to is the recipient the event is about. A group MMS has one status callback per recipient. As on Bandwidth, the text on a status callback is usually empty.
Sonaline:
- Media URLs in callbacks are always Sonaline's (
https://api.sonaline.com/api/v2/users/{accountId}/media/…), never a carrier's. They need your credentials, and they work as soon as the callback arrives: Sonaline stores the file before it calls you. On a status callback,mediais the list of URLs you sent. tagandpriorityon a status callback are the ones you sent.carrierNameis only included when the carrier reports it.
Checking that a callback is really from Sonaline
If your application has a callback user name and password, every callback carries them as HTTP Basic: Authorization: Basic base64(user:password). Check the header against your stored values, comparing in constant time, and refuse anything else with 401. This is how Bandwidth's callback credentials work too.
Your callback URL must be https on the standard port, on a public host name (not an IP address, and not one of Sonaline's own), with no user name or password in it, and at most 2000 characters. Do not put a secret in its query string: use the callback credentials. The callback user name may not contain :; it and the password are at most 200 characters each. Callbacks carry User-Agent: Sonaline-Messaging/2.
Optional: Sonaline's signature
Sonaline: this is an extra; Bandwidth does not send it, and you may ignore it. If your account has a webhook signing token (the same token the older YBPBX webhook is signed with; Sonaline gives it to you), every callback also carries:
X-Signature-256: t=<unix seconds>,v1=<hex HMAC-SHA256(token, "<t>.<raw body>")>
It is made exactly as the older webhook's X-Signature-256 is, so code you already have for that header works here unchanged. With no signing token on your account, the header is left off. To check it:
- Split the header on
,intot=andv1=. - Refuse it if
tis more than a few minutes from your clock (this is what stops a captured callback being replayed later). - Compute HMAC-SHA256 with your token as the key over
t, a., and the raw request body exactly as received, before any JSON parsing. - Compare the hex result with
v1in constant time.
A receiver in Node.js that checks both the callback credentials and the signature, answers at once and drops repeats:
Node.js
// Node.js 18 or later, no packages: a callback receiver that checks the
// callback credentials and, when your account has one, the signature.
const http = require("node:http");
const crypto = require("node:crypto");
const USER = process.env.CALLBACK_USER; // the application's callback user name
const PASSWORD = process.env.CALLBACK_PASSWORD; // and password
const SIGNING_TOKEN = process.env.SIGNING_TOKEN; // optional: your webhook signing token
function same(a, b) {
const x = Buffer.from(String(a)), y = Buffer.from(String(b));
return x.length === y.length && crypto.timingSafeEqual(x, y);
}
function basicOk(header) {
return same(header || "", "Basic " + Buffer.from(`${USER}:${PASSWORD}`).toString("base64"));
}
function signatureOk(header, rawBody, toleranceS = 300) {
const parts = Object.fromEntries(String(header || "").split(",").map((p) => p.split("=", 2)));
const t = Number(parts.t);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceS) return false;
const want = crypto.createHmac("sha256", SIGNING_TOKEN)
.update(`${parts.t}.`).update(rawBody).digest("hex");
return same(parts.v1 || "", want);
}
const seen = new Set(); // X-Delivery-Id values already handled; keep these in your database
http.createServer((req, res) => {
const chunks = [];
req.on("data", (chunk) => chunks.push(chunk));
req.on("end", () => {
const raw = Buffer.concat(chunks); // the raw body, before any JSON parsing
if (req.method !== "POST" || !basicOk(req.headers.authorization)
|| (SIGNING_TOKEN && !signatureOk(req.headers["x-signature-256"], raw))) {
res.writeHead(401).end();
return;
}
res.writeHead(204).end(); // answer within 10 seconds; do slow work after
const delivery = req.headers["x-delivery-id"];
if (delivery && seen.has(delivery)) return;
if (delivery) seen.add(delivery);
for (const event of JSON.parse(raw.toString("utf8"))) {
console.log(event.type, event.message.id, event.to, event.errorCode ?? "");
}
});
}).listen(8080);
The same in Python, with Flask:
Python
# Python 3 with Flask (pip install flask): a callback receiver that checks
# the callback credentials and, when your account has one, the signature.
import base64, hashlib, hmac, json, os, time
from flask import Flask, request
USER = os.environ["CALLBACK_USER"] # the application's callback user name
PASSWORD = os.environ["CALLBACK_PASSWORD"] # and password
SIGNING_TOKEN = os.environ.get("SIGNING_TOKEN", "") # optional: your webhook signing token
app = Flask(__name__)
seen = set() # X-Delivery-Id values already handled; keep these in your database
def basic_ok(header):
want = "Basic " + base64.b64encode(f"{USER}:{PASSWORD}".encode()).decode()
return hmac.compare_digest((header or "").encode(), want.encode())
def signature_ok(header, raw_body, tolerance_s=300):
try:
parts = dict(p.split("=", 1) for p in (header or "").split(","))
t = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - t) > tolerance_s:
return False
want = hmac.new(SIGNING_TOKEN.encode(), parts["t"].encode() + b"." + raw_body,
hashlib.sha256).hexdigest()
return hmac.compare_digest(parts.get("v1", "").encode(), want.encode())
@app.post("/sonaline/callbacks")
def callbacks():
raw = request.get_data() # the raw body, before any JSON parsing
if not basic_ok(request.headers.get("Authorization")):
return "", 401
if SIGNING_TOKEN and not signature_ok(request.headers.get("X-Signature-256"), raw):
return "", 401
delivery = request.headers.get("X-Delivery-Id")
if delivery and delivery in seen:
return "", 204
if delivery:
seen.add(delivery)
for event in json.loads(raw):
# Answer within 10 seconds: hand slow work to a queue.
print(event["type"], event["message"]["id"], event["to"], event.get("errorCode", ""))
return "", 204
A retry is signed afresh, with a new t.
To try your receiver before Sonaline calls it, post it a made-up callback (with no signing token set on your side):
curl
curl -s -X POST http://localhost:8080/sonaline/callbacks \
-u "$CALLBACK_USER:$CALLBACK_PASSWORD" \
-H 'Content-Type: application/json' \
-H 'X-Delivery-Id: d1' \
-d '[{"type":"message-received","time":"2026-11-01T15:43:35.502Z",
"description":"Incoming message received","to":"+12025550100",
"message":{"id":"14762070468292kw2fuqty55yp2b2","owner":"+12025550100",
"applicationId":"93de2206-9669-4e07-948d-329f4b722ee2",
"time":"2026-11-01T15:43:34.000Z","segmentCount":1,"direction":"in",
"to":["+12025550100"],"from":"+12025550123","text":"Hello"}}]'
Each callback also has an X-Delivery-Id header (d<number>). It is the same on every retry of one callback, so use it to drop repeats. Sonaline: this header is extra; Bandwidth does not send it.
Answering, and retries
Answer every callback with a 2xx within 10 seconds, as Bandwidth requires. Do slow work after you have answered.
Sonaline: a callback carrying media gets 30 seconds, so your server has time to fetch the file first.
If your server answers anything else, does not answer in time, or redirects (a 3xx counts as a failure), the callback is tried again. As on Bandwidth, retries run over the 24 hours after the first try and then stop. Sonaline retries about 5, 20 and 80 minutes after the first try, then after about 7 hours, and makes one last try at the 24-hour mark. Your workspace admin (and Sonaline) can see each attempt, in the admin center under Advanced › API & webhooks, and send a stuck one again.
Retries and Bandwidth's own redeliveries mean the same event can reach you more than once. De-duplicate on X-Delivery-Id, or on (message.id, type, to).
No application
A workspace with no application keeps its YBPBX-style webhook exactly as before (see Moving from the YBPBX API). If a workspace has both an application and a webhook URL, both are called, each in its own format, so every event reaches you twice. This lets you move over without missing anything. Tell Sonaline once your new callback handler is live, and Sonaline turns the old webhook off. That also turns off the old push of received files to your media URL, if you had one.
Errors
Errors use Bandwidth's body: {"type": "…", "description": "…"}. A refused send also has fieldErrors:
{
"type": "request-validation",
"description": "Your request could not be accepted",
"fieldErrors": [
{ "fieldName": "from", "description": "'+12025550199' is not one of this account's phone numbers" },
{ "fieldName": "to", "description": "'2025550123' is not a valid E.164 phone number" }
]
}
| Status | type | When |
|---|---|---|
| 400 | request-validation | A field is missing or wrong (see fieldErrors), a carrier's own limit is passed, or a media file is too big. Also an unusable filter or page, or a malformed media ID on upload. |
| 401 | unauthorized | No credentials, or wrong ones. |
| 403 | forbidden | Another account's ID, a credential without the messaging scope, the API is switched off for you, or the recipient opted out (STOP). |
| 404 | (framework body) | No such path or media file, or a malformed media ID on download or delete. As on Bandwidth, the body is {"timestamp","status":404,"error":"Not Found","message":"Not Found","path"}. |
| 405 | method-not-allowed | Wrong method for the path. On the token URL, OAuth's {"error":"invalid_request"} instead. |
| 409 | conflict | Sonaline: an upload past your account's media limit (1,000 files or 1 GB). |
| 415 | unsupported-content-type | A send whose body is not application/json, or an MMS layout document uploaded as media. (A SMIL file named in a send's media is a 400.) |
| 429 | message-limit-exceeded | Sonaline: your account's daily or monthly sending limit is spent. The description says which, and when it resets. |
| 500 | internal-server-error | Something failed on Sonaline's side, or the carrier refused the message or could not be reached (description says which). |
| 503 | service-unavailable | The carrier is busy. Try again in a minute. |
| 504 | gateway-timeout | Sonaline: the carrier did not answer in time. The message may have gone: do not resend automatically. |
A message that is accepted (202) and then fails is reported by a message-failed callback, with Bandwidth's own errorCode (for example 4720 invalid destination, 4750 rejected by the carrier, 4770 blocked as spam, 9902 no delivery receipt). Bandwidth's error code list applies as it is, because these codes come from the carrier.
Rules every message follows
These apply however you send:
- Your numbers only.
frommust be one of your account's numbers. - STOP is final. When someone texts STOP (or a similar word) to one of your numbers, sends to them from that number are refused with 403 until they text START. A group send is refused whole if any member opted out. You still get the STOP as a
message-receivedcallback. - Limits. Your account may have a daily and a monthly segment limit. Past it, sends answer 429.
- One history. Every message, sent or received, is kept in Sonaline, where your team can also see it in the Sonaline inbox, and is counted on your usage.
Moving from the YBPBX API
The older YBPBX-shaped API on api.sonaline.com (/api/sms…, x-api-key, the signed webhook) keeps working unchanged, beside this one. You can move one call at a time.
| YBPBX API | Messaging API (this guide) |
|---|---|
x-api-key: <key> header | Authorization: Bearer <token> from the token URL (or Basic with the client ID and secret) |
POST /api/sms {"to", "from", "text", "media"} | POST /api/v2/users/{accountId}/messages {"applicationId", "to": [ … ], "from", "text", "media"}. to is always an array; from is always required. |
answer 200 {"status":"queued","message_id",…} | answer 202 with the message object; message_id is now id |
POST /api/sms/media (raw body, ?name=) → a /api/sms/media?id=…&sig=… string | PUT /api/v2/users/{accountId}/media/{name} → 204; then use its URL |
GET /api/sms/media?id=…&sig=… | GET the media URL from the callback |
GET /api/sms/numbers | no Bandwidth equivalent: keep calling GET /api/sms/numbers, now with your Bearer token (see below) |
GET /api/sms?month=YYYY-MM (usage) | no Bandwidth equivalent: keep calling GET /api/sms, now with your Bearer token (see below) |
webhook sms_received | callback message-received |
webhook sms_sent | callback message-sent (and message-sending before it, for MMS) |
webhook sms_delivered | callback message-delivered |
webhook sms_failed, error: "<description> (code N)" | callback message-failed, with description and errorCode: N |
webhook field message_id | message.id |
webhook fields from, to, peers | message.from, message.to (every number on the message), and to at the top level |
webhook field segments | message.segmentCount |
webhook media: ["/api/sms/media?id=…&sig=…"] | message.media: ["https://api.sonaline.com/api/v2/users/…/media/…"] |
webhook signature X-Signature-256 / X-YBPBX-Signature with your token | callback Basic credentials, and the same X-Signature-256 under the same token as an optional extra (see Checking that a callback is really from Sonaline); no X-YBPBX-Signature |
X-Delivery-Id, retries | the same X-Delivery-Id; retries for 24 hours |
| media push to your media URL | none: fetch the media URL from the callback instead |
Usage and numbers with your new credentials
Bandwidth's Messaging API has no usage or number list, so these two calls stay on the older API. Sonaline: they take your new sign-in too, so you need no second credential once you have moved: the Bearer token from the token URL (or Basic with your client ID and secret). Each answers for your own account only, exactly as with x-api-key, which keeps working.
curl
# The numbers you may send from
curl -s https://api.sonaline.com/api/sms/numbers -H "Authorization: Bearer $TOKEN"
# Usage for a month (default: this month), with your daily and monthly limits
curl -s "https://api.sonaline.com/api/sms?month=2026-11" -H "Authorization: Bearer $TOKEN"
If a request carries an x-api-key header, that key alone decides, and a wrong one is refused even with a good token beside it. Sending on the older API (POST /api/sms, POST /api/sms/media) still takes x-api-key only: send with POST …/messages instead.
Message IDs are the same on both APIs. A message sent through /api/sms appears in the new list under the same ID, and media strings you already stored from /api/sms/media keep working with your x-api-key.
A suggested order:
- Get your credentials, account ID and application from Sonaline. Ask for the application to have a callback URL on your side and callback credentials.
- Build the callback handler and turn it on. During the switch your system gets each event twice, once in each format: once on the old webhook (YBPBX format) and once as a callback (Bandwidth format). Use the message ID to tell they are the same event.
- Move sending to
POST …/messages, and uploads toPUT …/media/…. Read usage and numbers with your Bearer token. - Tell Sonaline your callback handler is live. Sonaline then turns the old webhook off, and from then on events arrive once, as callbacks. Your old API key can be retired too.
Where Sonaline differs from Bandwidth
| Area | Bandwidth | Sonaline |
|---|---|---|
| Hosts | messaging.bandwidth.com/api/v2, token at api.bandwidth.com/api/v1/oauth2/token | api.sonaline.com/api/v2, token at api.sonaline.com/api/v1/oauth2/token |
| Credentials | made by you in the Bandwidth App | made and rotated by your workspace admin in the Sonaline admin center (or by Sonaline); the secret is shown once |
| Token | a JWT | an opaque string; ends at once if the secret is rotated |
| Basic auth | legacy API user and password, retired 2 Dec 2026 | the same client ID and secret; not being retired |
| Account ID | your Bandwidth account | a 7-digit Sonaline account ID for your workspace |
| Applications | made by you; numbers join them through locations | made by your workspace admin; each lists its own numbers |
from | any number on your account, or an alphanumeric sender ID | your Sonaline numbers only; no sender IDs |
priority, expiration | always honoured | honoured on Sonaline's Bandwidth numbers; stored but not honoured elsewhere |
| Status callbacks | for every message | only for messages sent through this API |
| Callback media URLs | Bandwidth's store | Sonaline's, on api.sonaline.com, kept with the message |
| Callback timeout | 10 s | 10 s; 30 s when the callback carries media |
| Retries | over 24 hours | over 24 hours (about 5, 20 and 80 min, 7 h, then at 24 h) |
| Extra headers | none | X-Delivery-Id; X-Signature-256 when your account has a signing token |
| List window | 14 days | everything kept for your account |
| List rows | one per recipient | one per message; a sent group's destinationTn lists every member |
| List: carrier, latency, product, location, country, size | filled where your account has the feature | Bandwidth's own values for your messages on Bandwidth in its 14 days; null otherwise; those filters reach 14 days back |
| List statuses | eight | RECEIVED, SENT, DELIVERED, FAILED |
| Unknown list parameter | ignored | 400 |
| Media IDs | file names | letters, digits, ., _, -; up to 200 characters |
| Media list | your files | your uploads only |
| Deleting a received attachment | allowed | refused (403): it stays with the message |
| Sending limits | Bandwidth's queue limits (429 max-message-queue-size-exceeded) | your account's daily and monthly limits (429 message-limit-exceeded) |
| Carrier did not answer | no such answer | 504 gateway-timeout: may have been sent, do not resend |
| STOP | handled by the carriers | also enforced by Sonaline: a send to someone who opted out is refused with 403 |
Not part of this API
These Bandwidth APIs are not offered. They could be added later:
- Voice (calls, BXML, recordings).
- Phone numbers: ordering, porting, search, and the account's number list.
- 10DLC and toll-free verification: brand and campaign registration.
- Number lookup (carrier and line type).
- Multi-Channel / RBM (
/messages/multiChannel). - Bandwidth's account-level reporting beyond the message list (Insights dashboards and exports). The list's own Insights fields are filled; see List and search messages.
Questions
Write to hello@sonaline.com.