Overview
Use the API when messages are triggered by your own systems: order updates from a shop, codes from a login form, reminders from a booking tool. A request to POST /v1/messages with up to 10,000 numbers creates one campaign, charges the balance and queues every message. Campaigns created by the API also appear in the console with their delivery reports.
- All amounts are US dollars as strings with five decimals, for example "0.00534".
- Phone numbers are international, with or without the leading +; spaces and dashes are removed.
- Times are ISO 8601 in UTC, for example 2026-12-01T09:00:00Z.
- Message and campaign ids look like MSG-XXXX-XXXX-XXXX and CMP-XXXX-XXXX-XXXX.
Authentication
Create an API key under API in the console (keys open after your first top-up is added). Send it in the Authorization header of every request. A key looks like bso_api_ followed by 36 characters; it is shown once, so store it as a secret on your server. Creating a new key replaces the previous one at once.
Authorization: Bearer bso_api_0123456789abcdef0123456789abcdef0123 Content-Type: application/json
Limits
| Requests | 300 a minute per key; more answer 429 rate_limited |
|---|---|
| Numbers per request | 10,000 |
| Message text | 2,000 characters as written, up to 10 segments |
| Scheduling | send_at from 5 minutes to 60 days ahead |
| Sending pace | 400 messages a minute per queue |
| Webhook retries | 9 tries over about 22 hours |
Send messages
POST/v1/messages
Sends one text to one or more numbers. The full cost is taken from the balance when the request is accepted; each message that cannot be delivered is refunded later. The answer has status 201 and the campaign with every message.
| Field | Type | Description |
|---|---|---|
torequired | string | string[] | One number or an array of up to 10,000 numbers in international format. Repeated numbers are sent once; unreadable ones are listed in skipped. |
textrequired | string | The message, up to 2,000 characters and 10 segments. GSM-7 text holds 160 characters per SMS, Unicode 70. |
send_atoptional | string | ISO 8601 time to schedule the send, 5 minutes to 60 days ahead. Leave it out to send now. |
opt_outoptional | boolean | true adds your opt-out line from the console settings on a new line at the end. |
client_refoptional | string | Your own reference, up to 64 letters, digits and _ . : -. It is returned in reports and webhooks. |
curl -X POST https://bulksmsonline.cc/api/v1/messages \
-H "Authorization: Bearer $BSO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"to":["+447700900412","+33612345678"],"text":"Order 4521 has shipped. Track it at shop.example/t/4521","client_ref":"order-4521"}'const res = await fetch("https://bulksmsonline.cc/api/v1/messages", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.BSO_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ to: ["+447700900412", "+33612345678"], text: "Order 4521 has shipped.", client_ref: "order-4521" }),
});
if (!res.ok) throw new Error((await res.json()).error.message);
const campaign = await res.json();import os, requests
res = requests.post("https://bulksmsonline.cc/api/v1/messages",
headers={"Authorization": f"Bearer {os.environ['BSO_API_KEY']}"},
json={"to": ["+447700900412", "+33612345678"], "text": "Order 4521 has shipped.", "client_ref": "order-4521"},
timeout=30)
res.raise_for_status()
campaign = res.json()<?php
$ch = curl_init("https://bulksmsonline.cc/api/v1/messages");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("BSO_API_KEY"), "Content-Type: application/json"],
CURLOPT_POSTFIELDS => json_encode(["to" => ["+447700900412", "+33612345678"], "text" => "Order 4521 has shipped.", "client_ref" => "order-4521"])]);
$campaign = json_decode(curl_exec($ch), true);HTTP/1.1 201 Created
{
"id": "CMP-7KQ2-M9XA-4HTD",
"status": "sending",
"channel": "api",
"recipients": 2,
"segments": 1,
"encoding": "GSM-7",
"cost": "0.02158",
"refunded": "0.00000",
"client_ref": "order-4521",
"send_at": null,
"counts": { "queued": 2, "submitted": 0, "delivered": 0, "failed": 0, "canceled": 0 },
"skipped": [],
"balance": "118.43120",
"messages": [
{ "id": "MSG-3RTW-81KD-ZQ0P", "to": "+447700900412", "country": "GB", "segments": 1, "price": "0.00834", "status": "queued" },
{ "id": "MSG-5HNA-4C2E-7YVB", "to": "+33612345678", "country": "FR", "segments": 1, "price": "0.01324", "status": "queued" }
]
}Quote a send
POST/v1/quote
Takes the same body as /v1/messages and returns the exact cost, segments and encoding without sending or charging anything. Use it to show a price to your own users before they confirm.
{
"recipients": 2,
"skipped": [],
"segments": 1,
"encoding": "GSM-7",
"cost": "0.02158",
"balance": "118.45278",
"countries": [ { "country": "GB", "recipients": 1, "cost": "0.00834" }, { "country": "FR", "recipients": 1, "cost": "0.01324" } ]
}Balance
GET/v1/balance
{ "balance": "118.43120", "currency": "USD" }Rates
GET/v1/rates
GET/v1/rates/{country}
The price of one SMS segment for every destination, or for one two-letter ISO country code. These two endpoints are public and need no key.
{ "country": "DE", "name": "Germany", "calling_code": "+49", "price": "0.01544", "currency": "USD", "per": "segment" }Message status
GET/v1/messages/{id}
{
"id": "MSG-3RTW-81KD-ZQ0P",
"campaign_id": "CMP-7KQ2-M9XA-4HTD",
"to": "+447700900412",
"country": "GB",
"segments": 1,
"price": "0.00834",
"status": "delivered",
"error": null,
"created_at": "2026-11-07T09:00:02Z",
"submitted_at": "2026-11-07T09:00:03Z",
"final_at": "2026-11-07T09:00:08Z"
}Campaigns
GET/v1/campaigns/{id}
GET/v1/campaigns/{id}/messages?offset=0&limit=100
The first returns the campaign with its counts per status; the second pages through its messages, up to 1,000 per page. Campaigns sent from the console can be read the same way with the id shown on their page.
Cancel a scheduled send
POST/v1/campaigns/{id}/cancel
Stops a campaign that was created with send_at and has not started yet. Every message is canceled and the full cost returns to the balance. A campaign that is already sending answers 409 not_scheduled.
Delivery webhooks
Add an HTTPS URL under API in the console to receive each final status (delivered or failed) as a POST request. Answer with any 2xx status within 8 seconds. Other answers and timeouts are retried after 20 seconds, 1, 5 and 15 minutes, then 1, 3, 6 and 12 hours, 9 tries in total. The same event can arrive twice, so use event_id to process it once.
POST https://your-server.example/sms-status
Content-Type: application/json
X-BulkSMSOnline-Signature: t=1794042008,v1=5f0c2a...e91b
{
"type": "message.status",
"event_id": "ev_8f1c22d0a4b97e63c1f0",
"sent_at": "2026-11-07T09:00:09Z",
"data": {
"message_id": "MSG-3RTW-81KD-ZQ0P",
"campaign_id": "CMP-7KQ2-M9XA-4HTD",
"to": "+447700900412",
"country": "GB",
"status": "delivered",
"error": null,
"segments": 1,
"price": "0.00834",
"currency": "USD",
"client_ref": "order-4521",
"final_at": "2026-11-07T09:00:08Z"
}
}Verify the signature
The header holds the Unix time t and the signature v1 = HMAC-SHA256 of the string "t.raw_body" with your webhook secret, in hex. Compute it over the raw body before parsing the JSON, compare in constant time and reject events older than five minutes.
import crypto from "node:crypto";
export function verify(rawBody, header, secret) {
const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header || "");
if (!m || Math.abs(Date.now() / 1000 - Number(m[1])) > 300) return false;
const want = crypto.createHmac("sha256", secret).update(m[1] + "." + rawBody).digest("hex");
return crypto.timingSafeEqual(Buffer.from(want), Buffer.from(m[2]));
}import hmac, hashlib, re, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
m = re.fullmatch(r"t=(\d+),v1=([0-9a-f]{64})", header or "")
if not m or abs(time.time() - int(m.group(1))) > 300:
return False
want = hmac.new(secret.encode(), m.group(1).encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(want, m.group(2))<?php
function verify(string $raw, string $header, string $secret): bool {
if (!preg_match('/^t=(\d+),v1=([0-9a-f]{64})$/', $header, $m)) return false;
if (abs(time() - (int)$m[1]) > 300) return false;
$want = hash_hmac('sha256', $m[1] . '.' . $raw, $secret);
return hash_equals($want, $m[2]);
}Statuses
| queued | Accepted and waiting to leave the queue |
|---|---|
| submitted | Handed to the destination network |
| delivered | Confirmed by the handset (final) |
| failed | Could not be delivered; the price is refunded (final) |
| canceled | Part of a scheduled campaign canceled before sending (final) |
Campaign status is scheduled, sending, complete or canceled.
Errors
Errors answer with an HTTP status and a JSON body: { "error": { "code": "insufficient_balance", "message": "..." } }. Nothing is charged when a request answers with an error.
| HTTP | Code | Meaning |
|---|---|---|
| 400 | invalid_request | A field is missing or not valid; the message says which. |
| 400 | invalid_json | The body is not JSON. |
| 400 | no_valid_numbers | None of the numbers could be read. |
| 401 | unauthorized | The key is missing, malformed or replaced. |
| 402 | insufficient_balance | The balance does not cover the cost; top up and send again. |
| 404 | not_found | Unknown id or endpoint. |
| 409 | not_scheduled | Only a scheduled campaign that has not started can be canceled. |
| 413 | too_many_recipients | More than 10,000 numbers in one request. |
| 429 | rate_limited | More than 300 requests in a minute. |
| 500 | server_error | Retry the request; nothing was charged. |