Bulk SMS API v1
A small REST API for sending bulk SMS from your own software: JSON in and out, Bearer keys, the same prices and checks as the console, and signed webhooks for delivery reports. Base URL https://smsbulk.cc/rest/v1
Overview
Send to up to 8,000 numbers per request, each with its own merge values, and the API prices every recipient with their own merged text, exactly like the console. Ask for a quote first with the same body to know the cost; nothing is sent or charged by a quote. Times are ISO 8601 in UTC, money is a string in US dollars with five decimals.
curl https://smsbulk.cc/rest/v1/send \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"recipients": [
{ "to": "+14155550142", "fields": { "name": "Maria" } },
{ "to": "+447400123456", "fields": { "name": "Tom" } }
],
"text": "Hi {{name}}, your order ships today.",
"client_ref": "order-batch-17"
}'const res = await fetch("https://smsbulk.cc/rest/v1/send", {
method: "POST",
headers: {
Authorization: "Bearer YOUR_API_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({
recipients: [
{ to: "+14155550142", fields: { name: "Maria" } },
{ to: "+447400123456", fields: { name: "Tom" } },
],
text: "Hi {{name}}, your order ships today.",
client_ref: "order-batch-17",
}),
});
const data = await res.json();
console.log(res.status, data.campaign?.id, data.campaign?.cost);import requests
res = requests.post(
"https://smsbulk.cc/rest/v1/send",
headers={"Authorization": "Bearer YOUR_API_KEY"},
json={
"recipients": [
{"to": "+14155550142", "fields": {"name": "Maria"}},
{"to": "+447400123456", "fields": {"name": "Tom"}},
],
"text": "Hi {{name}}, your order ships today.",
"client_ref": "order-batch-17",
},
timeout=30,
)
data = res.json()
print(res.status_code, data.get("campaign", {}).get("id"))<?php
$body = json_encode([
"recipients" => [
["to" => "+14155550142", "fields" => ["name" => "Maria"]],
["to" => "+447400123456", "fields" => ["name" => "Tom"]],
],
"text" => "Hi {{name}}, your order ships today.",
"client_ref" => "order-batch-17",
]);
$ch = curl_init("https://smsbulk.cc/rest/v1/send");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer YOUR_API_KEY", "Content-Type: application/json"],
CURLOPT_POSTFIELDS => $body,
]);
$data = json_decode(curl_exec($ch), true);
echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $data["campaign"]["id"] ?? "", PHP_EOL;Authentication
Create a key in the console under API and webhooks; keys open after your first credited top-up. Send it on every request as a Bearer token. A key is shown once; you can keep up to 5 keys and revoke any of them at once.
Authorization: Bearer smsb_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxRates
Public, no key needed. The price per SMS segment for every destination, or for one ISO code.
GEThttps://smsbulk.cc/rest/v1/rates
GEThttps://smsbulk.cc/rest/v1/rates/{ISO}
{
"country": "US",
"name": "United States",
"dial_code": "+1",
"rate": "0.00485",
"currency": "USD",
"per": "message segment"
}Credit
GEThttps://smsbulk.cc/rest/v1/credit
{ "credit": "125.40000", "currency": "USD", "account": "A418820037715" }Quote
Prices a send without sending it. Takes the same body as Send.
POSThttps://smsbulk.cc/rest/v1/quote
| Field | Type | Meaning |
|---|---|---|
| to | string or string[] | One number or a list. International form with + is safest; local numbers need default_country. |
| recipients | object[] | Instead of to: a list of { to, fields } where fields fill merge fields such as {{name}}. |
| text | string | The message, up to 2,000 characters and 10 segments after merging. |
| default_country | string | ISO code used for numbers written without +, such as GB. Falls back to the default saved in Settings. |
| opt_out | boolean | Adds your saved opt-out wording as a last line (save it in Settings first). |
{
"recipients": 2,
"messages": 2,
"segments": { "min": 1, "max": 1 },
"encoding": "GSM-7",
"total": "0.01240",
"currency": "USD",
"credit": "125.40000",
"credit_after": "125.38760",
"countries": [
{ "country": "US", "recipients": 1, "rate": "0.00485", "segments": 1, "subtotal": "0.00485" },
{ "country": "GB", "recipients": 1, "rate": "0.00755", "segments": 1, "subtotal": "0.00755" }
],
"left_out": []
}Send
Creates a campaign, takes its total from your credit and queues the messages. Answers 201 with the campaign. Numbers that cannot be sent are listed in left_out and never charged.
POSThttps://smsbulk.cc/rest/v1/send
| Field | Type | Meaning |
|---|---|---|
| to / recipients / text / default_country / opt_out | As in Quote. | |
| send_at | string | ISO 8601 time to start, 5 minutes to 90 days ahead. Leave out to send now. |
| name | string | Your label for the campaign, up to 80 characters. |
| client_ref | string | Your own reference, up to 64 characters, returned on every message and webhook. |
HTTP/1.1 201 Created
{
"campaign": {
"id": "C507318264409",
"name": null,
"status": "sending",
"source": "api",
"test": false,
"recipients": 2,
"cost": "0.01240",
"refunded": "0.00000",
"net_cost": "0.01240",
"currency": "USD",
"send_at": null,
"created_at": "2026-11-07T14:02:11.000Z",
"started_at": "2026-11-07T14:02:11.000Z",
"finished_at": null,
"client_ref": "order-batch-17"
},
"messages": 2,
"left_out": []
}Campaigns
GEThttps://smsbulk.cc/rest/v1/campaigns?limit=25&before={id}
GEThttps://smsbulk.cc/rest/v1/campaigns/{id}
GEThttps://smsbulk.cc/rest/v1/campaigns/{id}/messages?status=failed&limit=100&after={message id}
A single campaign includes counts per status: queued, sent, delivered, failed and canceled. Message lists page with after and has_more; up to 1,000 per page.
Messages
GEThttps://smsbulk.cc/rest/v1/messages/{id}
GEThttps://smsbulk.cc/rest/v1/messages?to=%2B14155550142&status=delivered&client_ref=order-batch-17&limit=50
{
"id": "M220947781305",
"campaign": "C507318264409",
"to": "+14155550142",
"country": "US",
"segments": 1,
"encoding": "GSM-7",
"price": "0.00485",
"status": "delivered",
"reason": null,
"reason_text": null,
"refunded": false,
"client_ref": "order-batch-17",
"created_at": "2026-11-07T14:02:11.000Z",
"sent_at": "2026-11-07T14:02:12.000Z",
"finished_at": "2026-11-07T14:02:16.000Z"
}Cancel a scheduled campaign
POSThttps://smsbulk.cc/rest/v1/campaigns/{id}/cancel
Works until the start time. The whole cost returns to your credit in one ledger line and the campaign status becomes canceled.
Webhooks
Set an endpoint URL in the console. We POST JSON for message.delivered, message.failed and campaign.done, plus test when you press Send test event. Each event carries four headers, and any 2xx answer within 10 seconds counts as received.
| Field | Type | Meaning |
|---|---|---|
| Smsbulk-Event | header | Event name, such as message.delivered. |
| Smsbulk-Delivery | header | Delivery id, the same on every retry of one event. |
| Smsbulk-Timestamp | header | Unix seconds when this attempt was signed. |
| Smsbulk-Signature | header | sha256= followed by the base64 HMAC-SHA256 of timestamp, a newline and the raw body, keyed with your sbw_ secret. |
Delivery is tried 8 times: at once, then after 1 minute, 5 minutes, 15 minutes, 1 hour, 3 hours, 6 hours and 12 hours. Each attempt shows in the console delivery log with its HTTP status and the start of your answer.
{
"id": "dlv_3f9a1c07b2e44d58a6c1",
"event": "message.failed",
"created": "2026-11-07T14:02:16.000Z",
"data": {
"message": "M220947781305",
"campaign": "C507318264409",
"to": "+447400123456",
"country": "GB",
"status": "failed",
"reason": "not_in_service",
"segments": 1,
"price": "0.00755",
"client_ref": "order-batch-17"
}
}Verify the signature
import crypto from "node:crypto";
// raw = the request body exactly as received (string), secret = your sbw_ signing secret
export function verify(raw, headers, secret) {
const ts = headers["smsbulk-timestamp"];
const want = "sha256=" + crypto.createHmac("sha256", secret).update(ts + "\n" + raw).digest("base64");
const got = headers["smsbulk-signature"] || "";
const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300;
return fresh && got.length === want.length && crypto.timingSafeEqual(Buffer.from(got), Buffer.from(want));
}import base64, hashlib, hmac, time
def verify(raw: bytes, headers, secret: str) -> bool:
ts = headers.get("Smsbulk-Timestamp", "")
mac = hmac.new(secret.encode(), ts.encode() + b"\n" + raw, hashlib.sha256).digest()
want = "sha256=" + base64.b64encode(mac).decode()
fresh = abs(time.time() - int(ts or 0)) < 300
return fresh and hmac.compare_digest(want, headers.get("Smsbulk-Signature", ""))Errors and limits
Errors use the HTTP status and a JSON body: {"error":{"code":"insufficient_credit","message":"..."}}. Nothing is charged when a request fails.
| Field | Type | Meaning |
|---|---|---|
| 400 bad_json | The body is not JSON. | |
| 401 unauthorized | Missing, malformed or revoked key. | |
| 402 insufficient_credit | The total is more than your credit. Add funds in the console. | |
| 404 not_found | No such endpoint, campaign, message or ISO code on this account. | |
| 409 not_scheduled | Cancel was asked for a campaign that already started. | |
| 422 text_missing, text_too_long, too_many_segments | Message empty, over 2,000 characters, or over 10 segments for a recipient. | |
| 422 no_recipients, too_many, recipients_missing | No sendable number, more than 8,000 numbers, or neither to nor recipients. | |
| 422 schedule_range, bad_send_at | send_at outside 5 minutes to 90 days, or not a valid time. | |
| 422 opt_out_unset | opt_out is true but no wording is saved in Settings. | |
| 429 rate_limited | More than 300 requests in a minute for this key. |
Every answer carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset.
Left-out codes
Numbers removed before pricing come back in left_out with their position in your list, the value you sent, a code and the plain reason.
| Field | Type | Meaning |
|---|---|---|
| no_country_code | No + and no default country. | |
| too_short / too_long | Under 8 or over 15 digits. | |
| not_a_number | Letters or symbols that are not part of a number. | |
| nanp_length | A +1 number without eleven digits. | |
| not_covered | A destination outside our list. | |
| duplicate | The same number appeared earlier in the request. | |
| suppressed | The number is on your do-not-send list. |
Statuses and reasons
| Field | Type | Meaning |
|---|---|---|
| queued | status | Waiting to leave our queue. |
| sent | status | Handed to the network, waiting for the delivery report. Most reports arrive within minutes; some networks take up to 24 hours. |
| delivered | status | The network confirmed the phone received it. |
| failed | status | Not delivered. The price was refunded to your credit. |
| canceled | status | Not sent because you canceled. Refunded. |
| Field | Type | Meaning |
|---|---|---|
| network_error | reason | Network error |
| switched_off | reason | Phone switched off or out of coverage |
| not_in_service | reason | Number not in service |
| blocked | reason | Blocked by the network or the recipient |
| landline | reason | Landline or number that cannot receive SMS |
| content_filter | reason | Rejected by the network's content filter |
| expired | reason | Expired: no report before the network's time limit |
| unknown | reason | Unknown network response |
| canceled | reason | Canceled before sending |
Questions about the API? Open a ticket in the console, or read the FAQ.