Quick start
- Create an account and top up your balance in the console.
- Open API keys and create a key. Keys can be created after the first top-up is credited. The key is shown once; store it as a secret.
- Send a POST request to
/smswith the numbers and the text. Read the returned campaign ID, then follow delivery in the console or through webhooks.
Authentication
Every request except the public rate list carries your key in the Authorization header. Keys start with sbk_live_. A new key replaces the old one immediately.
Authorization: Bearer sbk_live_YOUR_KEYSend SMS
POST/sms
Creates a campaign, charges it from the balance and queues every message. Numbers are cleaned and de-duplicated; those that cannot be used are returned in dropped with a reason and are not charged.
| Field | Type | Description |
|---|---|---|
torequired | string or array | One number or an array of up to 10,000 numbers in international format, for example "+447700900461" |
textrequired | string | Message text, up to 10 segments. GSM-7 text fits 160 characters per segment, UCS-2 fits 70 |
schedule_atoptional | string | ISO 8601 time from 5 minutes to 60 days ahead. Omit to send now |
client_refoptional | string | Your own reference, up to 64 of a-z A-Z 0-9 _ . : -, returned in reports and webhooks |
opt_outoptional | boolean | true appends the opt-out line set in your account settings |
curl -X POST https://sendbulksms.cc/api/v1/sms \
-H "Authorization: Bearer sbk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": ["+447700900461", "+5511955550123"],
"text": "Hi, your order 4821 ships today.",
"client_ref": "orders-4821"
}'const res = await fetch("https://sendbulksms.cc/api/v1/sms", {
method: "POST",
headers: { Authorization: "Bearer " + process.env.SENDBULKSMS_KEY, "Content-Type": "application/json" },
body: JSON.stringify({ to: ["+447700900461", "+5511955550123"], text: "Hi, your order 4821 ships today.", client_ref: "orders-4821" }),
});
const campaign = await res.json();
console.log(res.status, campaign.id, campaign.cost);import os, requests
r = requests.post(
"https://sendbulksms.cc/api/v1/sms",
headers={"Authorization": "Bearer " + os.environ["SENDBULKSMS_KEY"]},
json={"to": ["+447700900461", "+5511955550123"], "text": "Hi, your order 4821 ships today.", "client_ref": "orders-4821"},
timeout=30,
)
print(r.status_code, r.json()["id"])<?php
$ch = curl_init("https://sendbulksms.cc/api/v1/sms");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("SENDBULKSMS_KEY"), "Content-Type: application/json"],
CURLOPT_POSTFIELDS => json_encode(["to" => ["+447700900461", "+5511955550123"], "text" => "Hi, your order 4821 ships today.", "client_ref" => "orders-4821"]),
]);
$campaign = json_decode(curl_exec($ch), true);
echo $campaign["id"];Response: the campaign with its cost and the first 1,000 messages. Fetch more with campaign messages.
HTTP/1.1 201 Created
{
"id": "CP-7KQ2-M9XA-4HTD",
"status": "sending",
"source": "api",
"recipients": 2,
"segments": 1,
"encoding": "GSM-7",
"cost": "0.01078",
"refunded": "0.00000",
"client_ref": "orders-4821",
"scheduled_at": null,
"created_at": "2026-11-07T10:32:04.118Z",
"done_at": null,
"counts": { "queued": 2, "sent": 0, "delivered": 0, "failed": 0, "canceled": 0 },
"dropped": [],
"balance": "248.30422",
"messages": [
{ "id": "SM-2RBW-8MZN-QF6K", "campaign_id": "CP-7KQ2-M9XA-4HTD", "to": "+447700900461", "country": "GB", "parts": 1, "price": "0.00819", "status": "queued", "error": null, "created_at": "2026-11-07T10:32:04.118Z", "sent_at": null, "done_at": null }
]
}Estimate a send
POST/estimate
Takes the same body as /sms and returns the recipients, segments, encoding, total cost and the cost per country without charging or sending anything.
{ "recipients": 2, "dropped": [], "segments": 1, "encoding": "GSM-7", "cost": "0.01078", "balance": "248.31500",
"countries": [ { "country": "GB", "recipients": 1, "cost": "0.00819" }, { "country": "BR", "recipients": 1, "cost": "0.00259" } ] }Message status
GET/sms/{message_id}
Returns one message: number, country, segments, price, status, the error for failed messages and the time of each step.
Campaigns
GET/campaigns/{campaign_id}
The campaign with its cost, refunds and a count of messages per status.
GET/campaigns/{campaign_id}/messages?offset=0&limit=100
Messages in the order they were queued, up to 1,000 per page.
POST/campaigns/{campaign_id}/cancel
Cancels a scheduled campaign that has not started and refunds its full cost to the balance.
Balance and rates
GET/balance
{ "balance": "248.31500", "currency": "USD" }GET/rates
Public, no key needed. Every country with its calling code and price per segment. /rates/GB returns one country.
{ "updated": "2026-11-07", "rates": [ { "country": "GB", "name": "United Kingdom", "calling_code": "+44", "price": "0.00819", "currency": "USD", "unit": "SMS segment" } ] }Delivery webhooks
Set a webhook URL in the console under API keys. When a message reaches a final status (delivered or failed) we POST a report to it. Answer with any 2xx status within 8 seconds; otherwise the call is retried up to 8 times over about 11 hours. Use id to ignore a report you have already processed.
POST https://your-server.example/sms-status
Content-Type: application/json
X-SendBulkSMS-Signature: t=1794047524,v1=5f0c3b9e...
{
"event": "message.status",
"id": "evt_8c1d2e4f6a7b9c0d1e2f",
"created_at": "2026-11-07T10:32:09.004Z",
"data": {
"message_id": "SM-2RBW-8MZN-QF6K",
"campaign_id": "CP-7KQ2-M9XA-4HTD",
"to": "+447700900461",
"country": "GB",
"status": "delivered",
"error": null,
"parts": 1,
"price": "0.00819",
"client_ref": "orders-4821",
"done_at": "2026-11-07T10:32:08.912Z"
}
}Check the signature before trusting a report: HMAC-SHA256 of t + "." + raw body with your webhook secret, compared in constant time, and reject times older than 5 minutes.
import { createHmac, timingSafeEqual } from "node:crypto";
// raw = the request body exactly as received (string), header = X-SendBulkSMS-Signature
export function verify(raw, 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 = Buffer.from(createHmac("sha256", secret).update(m[1] + "." + raw).digest("hex"));
const got = Buffer.from(m[2]);
return want.length === got.length && timingSafeEqual(want, got);
}import hmac, hashlib, re, time
def verify(raw: 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, 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) || abs(time() - (int)$m[1]) > 300) return false;
$want = hash_hmac('sha256', $m[1] . '.' . $raw, $secret);
return hash_equals($want, $m[2]);
}Statuses
| Status | Meaning |
|---|---|
queued | Accepted and charged, waiting its turn in the queue |
sent | Handed to the route, waiting for the delivery report |
delivered | The network confirmed delivery to the handset |
failed | Not delivered; the price is refunded and error holds the reason |
canceled | Part of a scheduled campaign canceled before it started; refunded |
Errors
Errors use HTTP status codes and a JSON body: { "error": { "code": "insufficient_balance", "message": "..." } }. A request that returns an error never charges the balance.
| HTTP | Code | When |
|---|---|---|
| 400 | bad_requestA field is missing or not valid; the message explains which | A field is missing or not valid; the message explains which |
| 400 | no_valid_numbersNone of the numbers in "to" could be used | None of the numbers in "to" could be used |
| 401 | unauthorizedThe API key is missing or not valid | The API key is missing or not valid |
| 402 | insufficient_balanceThe balance does not cover the send; nothing was charged | The balance does not cover the send; nothing was charged |
| 404 | not_foundThe campaign, message or endpoint does not exist in your account | The campaign, message or endpoint does not exist in your account |
| 409 | not_scheduledOnly a scheduled campaign that has not started can be canceled | Only a scheduled campaign that has not started can be canceled |
| 413 | too_many_recipientsMore than 10,000 numbers in one request | More than 10,000 numbers in one request |
| 429 | rate_limitedMore than 300 requests in a minute for this key | More than 300 requests in a minute for this key |
| 500 | server_errorTry again; no charge is made for a request that fails | Try again; no charge is made for a request that fails |
Limits
- 300 requests a minute per API key.
- 10,000 numbers per send request; 10 segments per message.
- Messages leave at up to 200 a minute per queue, in the order they were accepted.
- Scheduling from 5 minutes to 60 days ahead.
- Numbers on your do-not-send list are skipped and returned in dropped with code suppressed; they are not charged.