Sonaline

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

BandwidthSonaline
Messaging APIhttps://messaging.bandwidth.com/api/v2https://api.sonaline.com/api/v2
Token URLhttps://api.bandwidth.com/api/v1/oauth2/tokenhttps://api.sonaline.com/api/v1/oauth2/token

You need:

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):

Sonaline:

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"
  }'
FieldRequired?Meaning
applicationIdYesOne of your applications. Status callbacks for this message go to its callback URL.
toYesAn array of E.164 numbers. More than one makes one group MMS between them all. No repeats.
fromYesOne of your account's numbers, in E.164.
texttext, media or bothAt most 2048 characters.
mediaSee textAn 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).
tagNoYour own string, at most 1024 characters. It comes back on this message's status callbacks.
priorityNodefault or high.
expirationNoAn 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:

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.

ParameterMeaning
messageIdOne message.
sourceTn, destinationTnA 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.
messageStatusRECEIVED, QUEUED, SENDING, SENT, FAILED, DELIVERED, ACCEPTED or UNDELIVERED.
messageDirectionINBOUND or OUTBOUND.
messageTypesms or mms.
errorCodeA failure code, for example 4750.
fromDateTime, toDateTimeISO 8601. Compared with receiveTime.
campaignIdYour 10DLC campaign ID.
fromSegmentCount, toSegmentCountA range of segment counts.
carrierNameThe carrier, for example Verizon (URL-encode AT&T as AT%26T).
fromBwLatency, fromCarrierLatencyThe least Bandwidth or carrier latency, in seconds (Advanced Quality Metrics; carrier latency is for OUTBOUND only).
bwQueued, carrierQueuedtrue or false: queued in Bandwidth's network, or the carrier's (carrierQueued is for OUTBOUND only).
productLOCAL_A2P, P2P, SHORT_CODE_REACH, TOLL_FREE, HOSTED_SHORT_CODE, ALPHA_NUMERIC, RBM_MEDIA, RBM_RICH or RBM_CONVERSATIONAL.
locationThe location ID.
callingNumberCountryA3, calledNumberCountryA3A three-letter country code, for example USA.
fromMessageSize, toMessageSizeA range of message sizes.
sortAny 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.
limitPage size. Default 100.
pageTokenFrom pageInfo.nextPageToken or prevPageToken. Pass the same filters again.
limitTotalCounttrue 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:

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:

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

Event types

typeDirectionMeaning
message-receivedinboundA customer texted one of your numbers.
message-sendingoutboundThe carrier is sending the message, typically sent for MMS.
message-sentoutboundThe message has left for the recipient's carrier.
message-deliveredoutboundThe recipient's handset has it.
message-failedoutboundIt 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:

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:

  1. Split the header on , into t= and v1=.
  2. Refuse it if t is more than a few minutes from your clock (this is what stops a captured callback being replayed later).
  3. Compute HMAC-SHA256 with your token as the key over t, a ., and the raw request body exactly as received, before any JSON parsing.
  4. Compare the hex result with v1 in 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" }
  ]
}
StatustypeWhen
400request-validationA 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.
401unauthorizedNo credentials, or wrong ones.
403forbiddenAnother 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"}.
405method-not-allowedWrong method for the path. On the token URL, OAuth's {"error":"invalid_request"} instead.
409conflictSonaline: an upload past your account's media limit (1,000 files or 1 GB).
415unsupported-content-typeA 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.)
429message-limit-exceededSonaline: your account's daily or monthly sending limit is spent. The description says which, and when it resets.
500internal-server-errorSomething failed on Sonaline's side, or the carrier refused the message or could not be reached (description says which).
503service-unavailableThe carrier is busy. Try again in a minute.
504gateway-timeoutSonaline: 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:

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 APIMessaging API (this guide)
x-api-key: <key> headerAuthorization: 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=… stringPUT /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/numbersno 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_receivedcallback message-received
webhook sms_sentcallback message-sent (and message-sending before it, for MMS)
webhook sms_deliveredcallback message-delivered
webhook sms_failed, error: "<description> (code N)"callback message-failed, with description and errorCode: N
webhook field message_idmessage.id
webhook fields from, to, peersmessage.from, message.to (every number on the message), and to at the top level
webhook field segmentsmessage.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 tokencallback 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, retriesthe same X-Delivery-Id; retries for 24 hours
media push to your media URLnone: 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:

  1. Get your credentials, account ID and application from Sonaline. Ask for the application to have a callback URL on your side and callback credentials.
  2. 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.
  3. Move sending to POST …/messages, and uploads to PUT …/media/…. Read usage and numbers with your Bearer token.
  4. 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

AreaBandwidthSonaline
Hostsmessaging.bandwidth.com/api/v2, token at api.bandwidth.com/api/v1/oauth2/tokenapi.sonaline.com/api/v2, token at api.sonaline.com/api/v1/oauth2/token
Credentialsmade by you in the Bandwidth Appmade and rotated by your workspace admin in the Sonaline admin center (or by Sonaline); the secret is shown once
Tokena JWTan opaque string; ends at once if the secret is rotated
Basic authlegacy API user and password, retired 2 Dec 2026the same client ID and secret; not being retired
Account IDyour Bandwidth accounta 7-digit Sonaline account ID for your workspace
Applicationsmade by you; numbers join them through locationsmade by your workspace admin; each lists its own numbers
fromany number on your account, or an alphanumeric sender IDyour Sonaline numbers only; no sender IDs
priority, expirationalways honouredhonoured on Sonaline's Bandwidth numbers; stored but not honoured elsewhere
Status callbacksfor every messageonly for messages sent through this API
Callback media URLsBandwidth's storeSonaline's, on api.sonaline.com, kept with the message
Callback timeout10 s10 s; 30 s when the callback carries media
Retriesover 24 hoursover 24 hours (about 5, 20 and 80 min, 7 h, then at 24 h)
Extra headersnoneX-Delivery-Id; X-Signature-256 when your account has a signing token
List window14 dayseverything kept for your account
List rowsone per recipientone per message; a sent group's destinationTn lists every member
List: carrier, latency, product, location, country, sizefilled where your account has the featureBandwidth's own values for your messages on Bandwidth in its 14 days; null otherwise; those filters reach 14 days back
List statuseseightRECEIVED, SENT, DELIVERED, FAILED
Unknown list parameterignored400
Media IDsfile namesletters, digits, ., _, -; up to 200 characters
Media listyour filesyour uploads only
Deleting a received attachmentallowedrefused (403): it stays with the message
Sending limitsBandwidth's queue limits (429 max-message-queue-size-exceeded)your account's daily and monthly limits (429 message-limit-exceeded)
Carrier did not answerno such answer504 gateway-timeout: may have been sent, do not resend
STOPhandled by the carriersalso 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:

Questions

Write to hello@sonaline.com.