# BulkSMSAPI API reference (full text) BulkSMSAPI is a bulk SMS API for developers: send one text or a batch of up to 10,000 numbers per request to 227 countries over REST, follow every message with signed webhooks, and pay per message from a prepaid balance. # Bulk SMS API reference Reference for the BulkSMSAPI REST API: base URL, authentication, endpoints for messages, batches, estimates, balance and prices, webhooks, errors and limits. BulkSMSAPI is a bulk SMS API for developers: send one text or a batch of up to 10,000 numbers per request to 227 countries over REST, follow every message with signed webhooks, and pay per message from a prepaid balance. This reference covers every endpoint, field, event and error the API has. Every page is also available as Markdown: add `.md` to its address. ## Base URL and format All requests go to `https://bulksmsapi.cc/v1` over HTTPS. Bodies are JSON (`Content-Type: application/json`, UTF-8), and every answer is JSON, including errors. Times are ISO 8601 in UTC. Money is a string in US dollars with five decimals (`"0.00507"`), so prices below a cent are exact. ## Endpoints | Method and path | What it does | Key | | --- | --- | --- | | POST /v1/messages | Send one text to one number | Yes | | POST /v1/batches | Send to up to 10,000 numbers, one text or one per number | Yes | | GET /v1/messages/{id} | One message with its status and price | Yes | | GET /v1/messages | Messages, newest first, filter by batch or status | Yes | | GET /v1/batches/{id} | One batch with counts per status | Yes | | GET /v1/batches | Batches, newest first | Yes | | POST /v1/batches/{id}/cancel | Cancel a scheduled batch, full refund | Yes | | POST /v1/estimates | Price a request without sending it | Yes | | GET /v1/balance | Balance in USD | Yes | | GET /v1/pricing | Price per segment for every country | No | | GET /v1/pricing/{country} | Price for one country (ISO code) | No | ## A first request Create an API key in the console, export it, and send a message. The answer is 202 with the message object: the cost is taken from the balance and the message is queued. ```bash curl https://bulksmsapi.cc/v1/messages \ -H "Authorization: Bearer $BULKSMSAPI_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "+14155550123", "text": "Your Northwind code is 482913. It expires in 10 minutes.", "reference": "login-7781" }' ``` ```javascript // Node.js 18 or newer (built-in fetch) const res = await fetch("https://bulksmsapi.cc/v1/messages", { method: "POST", headers: { Authorization: `Bearer ${process.env.BULKSMSAPI_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ to: "+14155550123", text: "Your Northwind code is 482913. It expires in 10 minutes.", reference: "login-7781", }), }); const message = await res.json(); console.log(res.status, message.id, message.status, message.price); ``` ```python # Python 3, standard library only import json, os, urllib.request req = urllib.request.Request( "https://bulksmsapi.cc/v1/messages", data=json.dumps({ "to": "+14155550123", "text": "Your Northwind code is 482913. It expires in 10 minutes.", "reference": "login-7781", }).encode(), headers={ "Authorization": "Bearer " + os.environ["BULKSMSAPI_KEY"], "Content-Type": "application/json", "User-Agent": "northwind-app/1.0", }, method="POST", ) with urllib.request.urlopen(req) as res: message = json.load(res) print(res.status, message["id"], message["status"], message["price"]) ``` ```php true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("BULKSMSAPI_KEY"), "Content-Type: application/json", ], CURLOPT_POSTFIELDS => json_encode([ "to" => "+14155550123", "text" => "Your Northwind code is 482913. It expires in 10 minutes.", "reference" => "login-7781", ]), ]); $message = json_decode(curl_exec($ch), true); echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $message["id"], " ", $message["status"], "\n"; ``` ```go // Go 1.20 or newer, standard library only package main import ( "bytes" "encoding/json" "fmt" "net/http" "os" ) func main() { body, _ := json.Marshal(map[string]any{ "to": "+14155550123", "text": "Your Northwind code is 482913. It expires in 10 minutes.", "reference": "login-7781", }) req, _ := http.NewRequest("POST", "https://bulksmsapi.cc/v1/messages", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+os.Getenv("BULKSMSAPI_KEY")) req.Header.Set("Content-Type", "application/json") res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() var message map[string]any json.NewDecoder(res.Body).Decode(&message) fmt.Println(res.StatusCode, message["id"], message["status"], message["price"]) } ``` 202 Accepted: ```json { "id": "msg_8F3K2Q9TZ1MX", "object": "message", "batch_id": "bat_4M7Q1X0PK2RS", "to": "+14155550123", "country": "US", "text": "Your Northwind code is 482913. It expires in 10 minutes.", "segments": 1, "price": "0.00507", "currency": "USD", "status": "queued", "error": null, "reference": "login-7781", "created_at": "2026-11-07T09:30:12.418Z", "sent_at": null, "done_at": null } ``` ## How a message moves Accepted messages are `queued` (or `scheduled` when the request has send_at). The queue hands them to the network every two seconds, up to 1,200 per minute per account, and they become `sent`. The final status is `delivered` or `failed`; a failed message is refunded to the balance at once. Each final status calls your webhook with a signed event. ## Limits at a glance | Limit | Value | | --- | --- | | Recipients per API request | 10,000 | | Recipients per console campaign | 50,000 | | Requests per minute per key | 600 | | Messages handed to the network per minute per account | 1,200 | | Longest text | 10 segments (1,530 GSM-7 or 670 UCS-2 characters) | | Schedule window | 2 minutes to 45 days ahead | | Webhook attempts per event | 10 | | Idempotency-Key memory | 24 hours | | API keys per account | 10 | ## Machine-readable files - [OpenAPI 3.1 spec](https://bulksmsapi.cc/openapi.json) for code generators and API clients. - [llms-full.txt](https://bulksmsapi.cc/llms-full.txt): this whole reference as one plain text file. - Every docs page as Markdown, for example [/en/docs/webhooks.md](https://bulksmsapi.cc/en/docs/webhooks.md). --- # Quickstart: send your first SMS by API Create an account, top up, create an API key and send your first SMS and your first batch with curl, Node.js, Python, PHP or Go. This guide takes you from no account to a delivered message and a signed delivery event. It needs a terminal and five minutes. ## 1. Create an account and top up Open [Get API key](https://bulksmsapi.cc/signup). Your browser makes an account key that starts with `acct_`; copy it or save it as a file, then confirm. That key is your login to the console. Add funds on the Billing page: the hosted checkout opens in the console, and the balance shows at the top of every console page. ## 2. Create an API key On the API keys page, name a key (for example `production` or `staging`) and create it. The key starts with `bsa_live_` and is shown once: copy it into your secret store or an environment variable. ```bash export BULKSMSAPI_KEY="bsa_live_..." ``` ## 3. Send a message Send a text to your own phone. Numbers are written in international format with the plus sign. ```bash curl https://bulksmsapi.cc/v1/messages \ -H "Authorization: Bearer $BULKSMSAPI_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "+14155550123", "text": "Your Northwind code is 482913. It expires in 10 minutes.", "reference": "login-7781" }' ``` ```javascript // Node.js 18 or newer (built-in fetch) const res = await fetch("https://bulksmsapi.cc/v1/messages", { method: "POST", headers: { Authorization: `Bearer ${process.env.BULKSMSAPI_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ to: "+14155550123", text: "Your Northwind code is 482913. It expires in 10 minutes.", reference: "login-7781", }), }); const message = await res.json(); console.log(res.status, message.id, message.status, message.price); ``` ```python # Python 3, standard library only import json, os, urllib.request req = urllib.request.Request( "https://bulksmsapi.cc/v1/messages", data=json.dumps({ "to": "+14155550123", "text": "Your Northwind code is 482913. It expires in 10 minutes.", "reference": "login-7781", }).encode(), headers={ "Authorization": "Bearer " + os.environ["BULKSMSAPI_KEY"], "Content-Type": "application/json", "User-Agent": "northwind-app/1.0", }, method="POST", ) with urllib.request.urlopen(req) as res: message = json.load(res) print(res.status, message["id"], message["status"], message["price"]) ``` ```php true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("BULKSMSAPI_KEY"), "Content-Type: application/json", ], CURLOPT_POSTFIELDS => json_encode([ "to" => "+14155550123", "text" => "Your Northwind code is 482913. It expires in 10 minutes.", "reference" => "login-7781", ]), ]); $message = json_decode(curl_exec($ch), true); echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $message["id"], " ", $message["status"], "\n"; ``` ```go // Go 1.20 or newer, standard library only package main import ( "bytes" "encoding/json" "fmt" "net/http" "os" ) func main() { body, _ := json.Marshal(map[string]any{ "to": "+14155550123", "text": "Your Northwind code is 482913. It expires in 10 minutes.", "reference": "login-7781", }) req, _ := http.NewRequest("POST", "https://bulksmsapi.cc/v1/messages", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+os.Getenv("BULKSMSAPI_KEY")) req.Header.Set("Content-Type", "application/json") res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() var message map[string]any json.NewDecoder(res.Body).Decode(&message) fmt.Println(res.StatusCode, message["id"], message["status"], message["price"]) } ``` The answer is `202 Accepted` with a message id, the price and the status `queued`. Within seconds the status moves to `sent` and then `delivered`. ## 4. Read the status ```bash curl https://bulksmsapi.cc/v1/messages/msg_8F3K2Q9TZ1MX \ -H "Authorization: Bearer $BULKSMSAPI_KEY" ``` ```javascript const res = await fetch("https://bulksmsapi.cc/v1/messages/msg_8F3K2Q9TZ1MX", { headers: { Authorization: `Bearer ${process.env.BULKSMSAPI_KEY}` }, }); const message = await res.json(); console.log(message.status, message.error); ``` ```python import json, os, urllib.request req = urllib.request.Request( "https://bulksmsapi.cc/v1/messages/msg_8F3K2Q9TZ1MX", headers={"Authorization": "Bearer " + os.environ["BULKSMSAPI_KEY"], "User-Agent": "northwind-app/1.0"}, ) with urllib.request.urlopen(req) as res: message = json.load(res) print(message["status"], message["error"]) ``` ```php true, CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("BULKSMSAPI_KEY")], ]); $message = json_decode(curl_exec($ch), true); echo $message["status"], "\n"; ``` ```go package main import ( "encoding/json" "fmt" "net/http" "os" ) func main() { req, _ := http.NewRequest("GET", "https://bulksmsapi.cc/v1/messages/msg_8F3K2Q9TZ1MX", nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("BULKSMSAPI_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() var message map[string]any json.NewDecoder(res.Body).Decode(&message) fmt.Println(message["status"], message["error"]) } ``` ## 5. Send a batch A batch sends one text to up to 10,000 numbers. Duplicates and numbers that cannot be read are listed in `set_aside` and not charged. ```bash curl https://bulksmsapi.cc/v1/batches \ -H "Authorization: Bearer $BULKSMSAPI_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: spring-sale-0412" \ -d '{ "name": "Spring sale", "text": "Spring sale at Northwind: 20% off until Sunday. Opt out: northwind.example/sms", "to": [ "+14155550123", "+447400123456", "+4915123456789" ] }' ``` ```javascript const res = await fetch("https://bulksmsapi.cc/v1/batches", { method: "POST", headers: { Authorization: `Bearer ${process.env.BULKSMSAPI_KEY}`, "Content-Type": "application/json", "Idempotency-Key": "spring-sale-0412", }, body: JSON.stringify({ name: "Spring sale", text: "Spring sale at Northwind: 20% off until Sunday. Opt out: northwind.example/sms", to: ["+14155550123", "+447400123456", "+4915123456789"], }), }); const batch = await res.json(); console.log(res.status, batch.id, batch.recipients, batch.cost); ``` ```python import json, os, urllib.request req = urllib.request.Request( "https://bulksmsapi.cc/v1/batches", data=json.dumps({ "name": "Spring sale", "text": "Spring sale at Northwind: 20% off until Sunday. Opt out: northwind.example/sms", "to": ["+14155550123", "+447400123456", "+4915123456789"], }).encode(), headers={ "Authorization": "Bearer " + os.environ["BULKSMSAPI_KEY"], "Content-Type": "application/json", "Idempotency-Key": "spring-sale-0412", "User-Agent": "northwind-app/1.0", }, method="POST", ) with urllib.request.urlopen(req) as res: batch = json.load(res) print(res.status, batch["id"], batch["recipients"], batch["cost"]) ``` ```php true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("BULKSMSAPI_KEY"), "Content-Type: application/json", "Idempotency-Key: spring-sale-0412", ], CURLOPT_POSTFIELDS => json_encode([ "name" => "Spring sale", "text" => "Spring sale at Northwind: 20% off until Sunday. Opt out: northwind.example/sms", "to" => ["+14155550123", "+447400123456", "+4915123456789"], ]), ]); $batch = json_decode(curl_exec($ch), true); echo $batch["id"], " ", $batch["recipients"], " ", $batch["cost"], "\n"; ``` ```go package main import ( "bytes" "encoding/json" "fmt" "net/http" "os" ) func main() { body, _ := json.Marshal(map[string]any{ "name": "Spring sale", "text": "Spring sale at Northwind: 20% off until Sunday. Opt out: northwind.example/sms", "to": []string{"+14155550123", "+447400123456", "+4915123456789"}, }) req, _ := http.NewRequest("POST", "https://bulksmsapi.cc/v1/batches", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+os.Getenv("BULKSMSAPI_KEY")) req.Header.Set("Content-Type", "application/json") req.Header.Set("Idempotency-Key", "spring-sale-0412") res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() var batch map[string]any json.NewDecoder(res.Body).Decode(&batch) fmt.Println(res.StatusCode, batch["id"], batch["recipients"], batch["cost"]) } ``` ## 6. Receive delivery events On the Webhooks page, add an HTTPS endpoint. Each delivered or failed message calls it with a signed JSON event; check the signature with the code on the [Webhooks](https://bulksmsapi.cc/en/docs/webhooks) page and answer with any 2xx status. > Next: read [Batches](https://bulksmsapi.cc/en/docs/batches) for personal text per number and scheduling, and [Errors](https://bulksmsapi.cc/en/docs/errors) for every error code and what it means for your balance. --- # Authentication with API keys BulkSMSAPI authenticates every request with a Bearer API key. How to create, store, rotate and revoke keys, and what happens to a wrong key. Every endpoint except the two pricing endpoints needs an API key in the Authorization header. ```http POST /v1/messages HTTP/1.1 Host: bulksmsapi.cc Authorization: Bearer bsa_live_7QzX2mW9kR4tB8nV1cJ6yP3sH5dL0aFe Content-Type: application/json ``` ## Creating keys - Keys are created on the API keys page of the console. The page opens after the first top-up is added to the balance. - An account can hold 10 active keys. Give each app or environment its own key and name, so one can be replaced without touching the others. - A key is `bsa_live_` followed by 32 letters and digits. It is shown once, when it is created; we keep only a keyed hash, so a lost key cannot be shown again. Create a new one instead. - The list shows the first characters, the last four, when the key was created and when it was last used. ## Rotating and revoking To rotate a key without downtime: create a second key, deploy it, check its last-used time in the console, then revoke the old key. A revoked key answers `401 unauthorized` from the next request on. ## Keeping keys safe - Call the API from your server, never from a browser or a mobile app: anyone with the key can send messages from your balance. - Keep keys in environment variables or a secret manager, not in source control. - Each key has its own rate limit counter, so a busy worker does not slow down a second key used elsewhere. ## Errors 401 Unauthorized: ```json { "error": { "code": "unauthorized", "message": "Send your API key as Authorization: Bearer bsa_live_... Keys are created in the console under API keys.", "doc_url": "https://bulksmsapi.cc/en/docs/errors#unauthorized" } } ``` --- # Send an SMS with POST /v1/messages Send one SMS to one number with POST /v1/messages, read it back with GET /v1/messages/{id} and list messages by batch or status. `POST /v1/messages` Send one text to one number. The cost is taken from the balance when the request is accepted. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | to | string | yes | The number in international format, for example +14155550123. Spaces, dashes, brackets and a 00 prefix are cleaned. | | text | string | yes | The message. Up to 10 segments; see Text and segments. | | reference | string | no | Your own id, up to 100 characters. It comes back in the message object and in every webhook. | | send_at | string | no | ISO 8601 time to send, 2 minutes to 45 days ahead. Without it the message is queued at once. | Add an `Idempotency-Key` header to make retries safe: see [Idempotency](https://bulksmsapi.cc/en/docs/idempotency). ```bash curl https://bulksmsapi.cc/v1/messages \ -H "Authorization: Bearer $BULKSMSAPI_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "+14155550123", "text": "Your Northwind code is 482913. It expires in 10 minutes.", "reference": "login-7781" }' ``` ```javascript // Node.js 18 or newer (built-in fetch) const res = await fetch("https://bulksmsapi.cc/v1/messages", { method: "POST", headers: { Authorization: `Bearer ${process.env.BULKSMSAPI_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ to: "+14155550123", text: "Your Northwind code is 482913. It expires in 10 minutes.", reference: "login-7781", }), }); const message = await res.json(); console.log(res.status, message.id, message.status, message.price); ``` ```python # Python 3, standard library only import json, os, urllib.request req = urllib.request.Request( "https://bulksmsapi.cc/v1/messages", data=json.dumps({ "to": "+14155550123", "text": "Your Northwind code is 482913. It expires in 10 minutes.", "reference": "login-7781", }).encode(), headers={ "Authorization": "Bearer " + os.environ["BULKSMSAPI_KEY"], "Content-Type": "application/json", "User-Agent": "northwind-app/1.0", }, method="POST", ) with urllib.request.urlopen(req) as res: message = json.load(res) print(res.status, message["id"], message["status"], message["price"]) ``` ```php true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("BULKSMSAPI_KEY"), "Content-Type: application/json", ], CURLOPT_POSTFIELDS => json_encode([ "to" => "+14155550123", "text" => "Your Northwind code is 482913. It expires in 10 minutes.", "reference" => "login-7781", ]), ]); $message = json_decode(curl_exec($ch), true); echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $message["id"], " ", $message["status"], "\n"; ``` ```go // Go 1.20 or newer, standard library only package main import ( "bytes" "encoding/json" "fmt" "net/http" "os" ) func main() { body, _ := json.Marshal(map[string]any{ "to": "+14155550123", "text": "Your Northwind code is 482913. It expires in 10 minutes.", "reference": "login-7781", }) req, _ := http.NewRequest("POST", "https://bulksmsapi.cc/v1/messages", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+os.Getenv("BULKSMSAPI_KEY")) req.Header.Set("Content-Type", "application/json") res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() var message map[string]any json.NewDecoder(res.Body).Decode(&message) fmt.Println(res.StatusCode, message["id"], message["status"], message["price"]) } ``` 202 Accepted: ```json { "id": "msg_8F3K2Q9TZ1MX", "object": "message", "batch_id": "bat_4M7Q1X0PK2RS", "to": "+14155550123", "country": "US", "text": "Your Northwind code is 482913. It expires in 10 minutes.", "segments": 1, "price": "0.00507", "currency": "USD", "status": "queued", "error": null, "reference": "login-7781", "created_at": "2026-11-07T09:30:12.418Z", "sent_at": null, "done_at": null } ``` ## The message object **Fields** | Name | Type | Required | Description | | --- | --- | --- | --- | | id | string | yes | Message id, msg_ followed by 12 characters. | | object | string | yes | Always message. | | batch_id | string | yes | The batch the message belongs to (a single message also gets a batch). | | to | string | yes | The number in E.164 format, after cleaning. | | country | string | yes | ISO 3166 code of the country the number was priced as. | | text | string | yes | The text as sent, merge fields filled. | | segments | integer | yes | Number of SMS segments the text uses. | | price | string | yes | Price of this message in USD, 5 decimals (price per segment times segments). | | currency | string | yes | Always USD. | | status | string | yes | scheduled, queued, sent, delivered, failed or canceled. | | error | object or null | yes | For failed or canceled messages: code and message. | | reference | string or null | yes | Your own reference from the request. | | created_at | string | yes | When the request was accepted (ISO 8601, UTC). | | sent_at | string or null | yes | When the message was handed to the network. | | done_at | string or null | yes | When the final status (delivered, failed or canceled) was set. | ## Retrieve a message `GET /v1/messages/{id}` One message by its msg_ id. Messages of other accounts answer 404. ```bash curl https://bulksmsapi.cc/v1/messages/msg_8F3K2Q9TZ1MX \ -H "Authorization: Bearer $BULKSMSAPI_KEY" ``` ```javascript const res = await fetch("https://bulksmsapi.cc/v1/messages/msg_8F3K2Q9TZ1MX", { headers: { Authorization: `Bearer ${process.env.BULKSMSAPI_KEY}` }, }); const message = await res.json(); console.log(message.status, message.error); ``` ```python import json, os, urllib.request req = urllib.request.Request( "https://bulksmsapi.cc/v1/messages/msg_8F3K2Q9TZ1MX", headers={"Authorization": "Bearer " + os.environ["BULKSMSAPI_KEY"], "User-Agent": "northwind-app/1.0"}, ) with urllib.request.urlopen(req) as res: message = json.load(res) print(message["status"], message["error"]) ``` ```php true, CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("BULKSMSAPI_KEY")], ]); $message = json_decode(curl_exec($ch), true); echo $message["status"], "\n"; ``` ```go package main import ( "encoding/json" "fmt" "net/http" "os" ) func main() { req, _ := http.NewRequest("GET", "https://bulksmsapi.cc/v1/messages/msg_8F3K2Q9TZ1MX", nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("BULKSMSAPI_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() var message map[string]any json.NewDecoder(res.Body).Decode(&message) fmt.Println(message["status"], message["error"]) } ``` ## List messages `GET /v1/messages` Messages of the account, newest first. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | batch_id | string | no | Only messages of this bat_ batch. | | status | string | no | scheduled, queued, sent, delivered, failed or canceled. | | limit | integer | no | 1 to 100, default 20. | | starting_after | string | no | A msg_ id: the page starts after it. Use the last id of the previous page. | ```bash curl "https://bulksmsapi.cc/v1/messages?status=failed&limit=50" \ -H "Authorization: Bearer $BULKSMSAPI_KEY" ``` 200 OK: ```json { "object": "list", "data": [ { "id": "msg_8F3K2Q9TZ1MX", "object": "message", "status": "failed", "...": "..." } ], "has_more": false } ``` --- # Send bulk SMS with POST /v1/batches Send one text to up to 10,000 numbers, or a personal text per number, with POST /v1/batches. Scheduling, set-aside numbers, counts and cancel. `POST /v1/batches` Send to up to 10,000 numbers in one request. The whole cost is taken from the balance at once. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | to | array of strings | no | Numbers for one shared text. Use either to with text, or messages. | | text | string | no | The shared text, required with to. | | messages | array of objects | no | One {to, text} per recipient, for personal texts. | | name | string | no | A label for the console, up to 100 characters. | | reference | string | no | Your own id, up to 100 characters, returned on the batch and every message. | | send_at | string | no | ISO 8601 time, 2 minutes to 45 days ahead. | ```bash curl https://bulksmsapi.cc/v1/batches \ -H "Authorization: Bearer $BULKSMSAPI_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: spring-sale-0412" \ -d '{ "name": "Spring sale", "text": "Spring sale at Northwind: 20% off until Sunday. Opt out: northwind.example/sms", "to": [ "+14155550123", "+447400123456", "+4915123456789" ] }' ``` ```javascript const res = await fetch("https://bulksmsapi.cc/v1/batches", { method: "POST", headers: { Authorization: `Bearer ${process.env.BULKSMSAPI_KEY}`, "Content-Type": "application/json", "Idempotency-Key": "spring-sale-0412", }, body: JSON.stringify({ name: "Spring sale", text: "Spring sale at Northwind: 20% off until Sunday. Opt out: northwind.example/sms", to: ["+14155550123", "+447400123456", "+4915123456789"], }), }); const batch = await res.json(); console.log(res.status, batch.id, batch.recipients, batch.cost); ``` ```python import json, os, urllib.request req = urllib.request.Request( "https://bulksmsapi.cc/v1/batches", data=json.dumps({ "name": "Spring sale", "text": "Spring sale at Northwind: 20% off until Sunday. Opt out: northwind.example/sms", "to": ["+14155550123", "+447400123456", "+4915123456789"], }).encode(), headers={ "Authorization": "Bearer " + os.environ["BULKSMSAPI_KEY"], "Content-Type": "application/json", "Idempotency-Key": "spring-sale-0412", "User-Agent": "northwind-app/1.0", }, method="POST", ) with urllib.request.urlopen(req) as res: batch = json.load(res) print(res.status, batch["id"], batch["recipients"], batch["cost"]) ``` ```php true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("BULKSMSAPI_KEY"), "Content-Type: application/json", "Idempotency-Key: spring-sale-0412", ], CURLOPT_POSTFIELDS => json_encode([ "name" => "Spring sale", "text" => "Spring sale at Northwind: 20% off until Sunday. Opt out: northwind.example/sms", "to" => ["+14155550123", "+447400123456", "+4915123456789"], ]), ]); $batch = json_decode(curl_exec($ch), true); echo $batch["id"], " ", $batch["recipients"], " ", $batch["cost"], "\n"; ``` ```go package main import ( "bytes" "encoding/json" "fmt" "net/http" "os" ) func main() { body, _ := json.Marshal(map[string]any{ "name": "Spring sale", "text": "Spring sale at Northwind: 20% off until Sunday. Opt out: northwind.example/sms", "to": []string{"+14155550123", "+447400123456", "+4915123456789"}, }) req, _ := http.NewRequest("POST", "https://bulksmsapi.cc/v1/batches", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+os.Getenv("BULKSMSAPI_KEY")) req.Header.Set("Content-Type", "application/json") req.Header.Set("Idempotency-Key", "spring-sale-0412") res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() var batch map[string]any json.NewDecoder(res.Body).Decode(&batch) fmt.Println(res.StatusCode, batch["id"], batch["recipients"], batch["cost"]) } ``` 202 Accepted: ```json { "id": "bat_6TQ2W8M1ZK4N", "object": "batch", "name": "Spring sale", "status": "queued", "source": "api", "encoding": "GSM-7", "recipients": 3, "max_segments": 1, "cost": "0.02741", "refunded": "0.00000", "currency": "USD", "counts": { "scheduled": 0, "queued": 3, "sent": 0, "delivered": 0, "failed": 0, "canceled": 0 }, "send_at": null, "reference": null, "created_at": "2026-11-07T09:31:40.102Z", "completed_at": null, "set_aside": [] } ``` ## Numbers that are set aside Every number is cleaned and priced on its own. Numbers that cannot be sent are left out of the batch, not charged, and listed in `set_aside` with a reason: | reason | Meaning | | --- | --- | | invalid_number | Not readable as + and 7 to 15 digits after cleaning. | | duplicate | The same number appeared earlier in the request. | | unsupported_country | The number does not belong to a priced country. | | too_long | Its personal text needs more than 10 segments. | | empty_text | Its personal text is empty. | | suppressed | The number is on your do-not-send list. | If no number is left, the request fails with the code of the first reason (for example `invalid_number`) and nothing is charged. ## Personal text per number Use `messages` to give each recipient their own text: names, times, amounts. Each text is counted and priced on its own, so a batch can mix one-segment and two-segment messages. ```bash curl https://bulksmsapi.cc/v1/batches \ -H "Authorization: Bearer $BULKSMSAPI_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Appointment reminders, 14 May", "send_at": "2026-05-13T16:00:00Z", "messages": [ { "to": "+14155550123", "text": "Hi Ana, your dental check-up is tomorrow at 09:30." }, { "to": "+14155550178", "text": "Hi Sam, your dental check-up is tomorrow at 11:00." } ] }' ``` ## Scheduling With `send_at` the batch and its messages wait in status `scheduled` and move to the queue at that minute. The cost is taken when the request is accepted, so a scheduled batch always has its funds. A scheduled batch can be canceled until it starts. `POST /v1/batches/{id}/cancel` Cancels a scheduled batch: every message becomes canceled and the full cost returns to the balance as one refund line. A batch that has started answers 409 not_cancelable. ```bash curl -X POST https://bulksmsapi.cc/v1/batches/bat_6TQ2W8M1ZK4N/cancel \ -H "Authorization: Bearer $BULKSMSAPI_KEY" ``` ## Retrieve and list batches `GET /v1/batches/{id}` The batch with live counts per status. Poll it for progress, or use webhooks. `GET /v1/batches` Batches of the account, newest first, with limit (1 to 100) and starting_after (a bat_ id). **Batch fields** | Name | Type | Required | Description | | --- | --- | --- | --- | | id | string | yes | bat_ followed by 12 characters. | | status | string | yes | scheduled, queued, sending, completed or canceled. | | source | string | yes | api or console. | | encoding | string | yes | GSM-7, or UCS-2 when any text needs Unicode. | | recipients | integer | yes | Numbers accepted into the batch. | | max_segments | integer | yes | Largest segment count among the texts. | | cost | string | yes | Total taken from the balance, USD. | | refunded | string | yes | Total returned so far for failed or canceled messages. | | counts | object | yes | Messages per status: scheduled, queued, sent, delivered, failed, canceled. | | send_at | string or null | yes | Scheduled time, if any. | | completed_at | string or null | yes | When the last message reached a final status. | ## Larger lists Split lists over 10,000 numbers into several requests, each with its own Idempotency-Key (for example the list id and the part number). Messages are handed to the network in order at up to 1,200 per minute per account, so a 50,000-number campaign is on its way in under an hour. The console takes 50,000 numbers per campaign from a paste or CSV file. --- # Phone numbers and country detection How the SMS API reads phone numbers: E.164 format, automatic cleaning, duplicates, and how shared calling codes like +1, +44, +7 and +262 are priced. Write numbers in E.164: a plus sign, the calling code and the national number without its trunk prefix, 7 to 15 digits in total. `+14155550123`, `+447400123456` and `+4915123456789` are all E.164. ## Cleaning - Spaces, dots, dashes, slashes and brackets are removed: `+44 (0)7400-123 456` is not valid because of the (0), but `+44 7400-123 456` is. - A leading `00` becomes `+`: `004915123456789` reads as `+4915123456789`. - Digits without a plus are read as international: `14155550123` becomes `+14155550123`. Store numbers with their calling code. - Numbers that are still not + and 7 to 15 digits are set aside as `invalid_number`. - The same number twice in one request is sent once; the second copy is set aside as `duplicate`. ## Country detection Each number is priced by the country it belongs to, read from the longest matching calling code. Some calling codes are shared, and the API tells those countries apart by prefix: | Calling code | How it is split | | --- | --- | | +1 | By area code: the Canadian codes price as Canada, Caribbean codes (809, 829, 849 Dominican Republic, 876 and 658 Jamaica, 787 and 939 Puerto Rico and the rest) price as their island, all other +1 numbers as the United States. | | +44 | Guernsey (7781, 7839, 7911), Jersey (7509, 7700, 7797, 7829, 7937) and the Isle of Man (7524, 7624, 7924) mobile prefixes price as those islands; the rest as the United Kingdom. | | +7 | +7 6 and +7 7 price as Kazakhstan, the rest as Russia. | | +262 | +262 639 and +262 269 price as Mayotte, the rest as Reunion. | | +39 | Italy, including Vatican City lines. | The matched country is in the `country` field of every message, so you can check how a number was priced. --- # Text, encoding and SMS segments How SMS length is counted: GSM-7 and UCS-2 encoding, 160 and 70 character segments, extension characters, emoji, and how segments set the price. An SMS carries 140 bytes. How many characters fit depends on the alphabet the text needs, and the price of a message is the price per segment times its segments. | Encoding | One segment | Each part of a longer text | 10 segments | | --- | --- | --- | --- | | GSM-7 | 160 characters | 153 characters | 1,530 characters | | UCS-2 (Unicode) | 70 characters | 67 characters | 670 characters | ## GSM-7 GSM-7 covers the Latin letters A to Z and a to z, digits, common punctuation and a set of accented letters: à é è ì ò ù Ç Ø ø Å å Ä ä Ö ö Ñ ñ Ü ü É Æ æ ß, plus Greek capitals used in some countries. These extension characters count as two: `^ { } [ ] ~ | \ €`. ## UCS-2 One character outside GSM-7 switches the whole message to UCS-2. That includes Cyrillic, Arabic, Chinese and every other non-Latin script, accents such as á í ó ú ç ã, curly quotes pasted from a word processor, and emoji. An emoji counts as two characters, because it takes two UTF-16 units. > A single curly apostrophe (’) in an English text halves the characters per segment. The console composer names each character that forces Unicode, and `POST /v1/estimates` returns the encoding before you send. ## Long messages Texts longer than one segment are split and joined again on the handset, which is why each part holds a little less. The API accepts up to 10 segments; longer texts answer `text_too_long`. ## Opt-out lines and merge fields The console adds your opt-out wording from Settings (for example "Opt out: northwind.example/sms") to campaign texts and fills merge fields `{{1}}` to `{{9}}` from the columns of a pasted list or CSV. Through the API you control the text completely: add the line yourself, and use `messages` in a batch for personal text. --- # Message status and delivery reports Every SMS status from scheduled to delivered or failed, when each one is set, which ones are refunded, and how to read them by webhook or by polling. | status | Meaning | Final | Charged | | --- | --- | --- | --- | | scheduled | Waiting for send_at. | No | Yes, refunded if canceled | | queued | Accepted and waiting for the next queue pass (every two seconds). | No | Yes | | sent | Handed to the network; waiting for the delivery report. | No | Yes | | delivered | The handset confirmed delivery. | Yes | Yes | | failed | Could not be delivered; error says why. | Yes | No, refunded | | canceled | Its scheduled batch was canceled. | Yes | No, refunded | ## Error codes on messages | error.code | Meaning | | --- | --- | | network_error | The message could not be handed to the network. Its price is back in the balance. | | undeliverable | The network reported that the handset could not be reached. | | canceled | The scheduled batch was canceled before it started. | ## Refunds When a message fails, its full price goes back to the balance in the same pass, as a refund line in the ledger that names the batch. The batch field `refunded` shows the running total. ## Webhooks or polling Webhooks are the fast path: each final status calls your endpoint once it is set. For small volumes, or to double-check, poll `GET /v1/messages/{id}` or `GET /v1/batches/{id}`; the rate limit leaves room for both. --- # Delivery webhooks with signatures Receive message.delivered and message.failed events on your HTTPS endpoint, verify the webhook-signature header, and handle 10 retries and duplicates. Set an HTTPS endpoint on the Webhooks page of the console. When a message reaches a final status, we send one POST with a JSON event to that endpoint. ## Events | type | When | | --- | --- | | message.delivered | The message was delivered. | | message.failed | The message failed; data.error has the code. Its price has been refunded. | POST to your endpoint: ```json { "id": "evt_8F3K2Q9TZ1MXD", "type": "message.delivered", "created_at": "2026-11-07T09:30:17.902Z", "data": { "id": "msg_8F3K2Q9TZ1MX", "object": "message", "batch_id": "bat_4M7Q1X0PK2RS", "to": "+14155550123", "country": "US", "text": "Your Northwind code is 482913. It expires in 10 minutes.", "segments": 1, "price": "0.00507", "currency": "USD", "status": "delivered", "error": null, "reference": "login-7781", "created_at": "2026-11-07T09:30:12.418Z", "sent_at": "2026-11-07T09:30:13.020Z", "done_at": "2026-11-07T09:30:17.902Z" } } ``` ## Headers and signature Every call carries three headers, in the Standard Webhooks format: | Header | Value | | --- | --- | | webhook-id | The event id (evt_...). The same id is used for every retry of one event. | | webhook-timestamp | Unix time in seconds when this attempt was signed. | | webhook-signature | v1, followed by the base64 HMAC-SHA256 signature. Several signatures may be listed, separated by spaces: accept the call when any one matches. | To check a call: take the secret from the console (it starts with `whsec_`), base64url-decode the part after `whsec_` to get the key, compute HMAC-SHA256 of `webhook-id + "." + webhook-timestamp + "." + raw body`, base64-encode it and compare it with the signature in constant time. Reject timestamps more than five minutes away from your clock. Always use the raw body: a parsed and re-serialised JSON body will not match. ```javascript // Express: keep the raw body, check the signature, then answer 2xx quickly import crypto from "node:crypto"; import express from "express"; const app = express(); const secret = process.env.BULKSMSAPI_WEBHOOK_SECRET; // whsec_... app.post("/hooks/sms", express.raw({ type: "application/json" }), (req, res) => { const id = req.header("webhook-id"); const ts = req.header("webhook-timestamp"); const key = Buffer.from(secret.slice("whsec_".length), "base64url"); const expected = crypto.createHmac("sha256", key).update(`${id}.${ts}.${req.body}`).digest("base64"); const sent = (req.header("webhook-signature") || "").split(" ").map((s) => s.replace(/^v1,/, "")); const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300; if (!fresh || !sent.some((s) => s.length === expected.length && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)))) { return res.status(401).end(); } const event = JSON.parse(req.body); console.log(event.type, event.data.id, event.data.status); res.status(204).end(); }); app.listen(3000); ``` ```python # Flask: check the signature on the raw body before parsing it import base64, hashlib, hmac, json, os, time from flask import Flask, abort, request app = Flask(__name__) SECRET = os.environ["BULKSMSAPI_WEBHOOK_SECRET"] # whsec_... @app.post("/hooks/sms") def sms_hook(): msg_id = request.headers.get("webhook-id", "") ts = request.headers.get("webhook-timestamp", "0") raw = request.get_data() key = base64.urlsafe_b64decode(SECRET[len("whsec_"):] + "==") signed = msg_id.encode() + b"." + ts.encode() + b"." + raw expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode() sent = [s.split(",", 1)[-1] for s in request.headers.get("webhook-signature", "").split()] if abs(time.time() - int(ts)) > 300 or not any(hmac.compare_digest(s, expected) for s in sent): abort(401) event = json.loads(raw) print(event["type"], event["data"]["id"], event["data"]["status"]) return "", 204 ``` ```php 300) { http_response_code(401); exit; } $event = json_decode($raw, true); error_log($event["type"] . " " . $event["data"]["id"]); http_response_code(204); ``` ```go package main import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "io" "log" "math" "net/http" "os" "strconv" "strings" "time" ) func smsHook(w http.ResponseWriter, r *http.Request) { secret := os.Getenv("BULKSMSAPI_WEBHOOK_SECRET") // whsec_... key, _ := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_")) raw, _ := io.ReadAll(r.Body) id, ts := r.Header.Get("webhook-id"), r.Header.Get("webhook-timestamp") mac := hmac.New(sha256.New, key) mac.Write([]byte(id + "." + ts + "." + string(raw))) expected := base64.StdEncoding.EncodeToString(mac.Sum(nil)) sec, _ := strconv.ParseInt(ts, 10, 64) ok := false for _, part := range strings.Fields(r.Header.Get("webhook-signature")) { sig := part[strings.Index(part, ",")+1:] if hmac.Equal([]byte(sig), []byte(expected)) { ok = true } } if !ok || math.Abs(float64(time.Now().Unix()-sec)) > 300 { w.WriteHeader(http.StatusUnauthorized) return } var event struct { Type string Data map[string]any } json.Unmarshal(raw, &event) log.Println(event.Type, event.Data["id"], event.Data["status"]) w.WriteHeader(http.StatusNoContent) } func main() { http.HandleFunc("/hooks/sms", smsHook) log.Fatal(http.ListenAndServe(":3000", nil)) } ``` ## Answering and retries - Answer with any 2xx status within 10 seconds. Do the slow work after answering, from a queue. - Redirects are not followed: point the setting at the final address. - A call that times out or answers anything other than 2xx is tried again after 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour, 2 hours, 4 hours, 8 hours, 8 hours: 10 attempts in all, about 24 hours from the first one. - Every attempt is listed on the Webhooks page with the HTTP status and the time it took. ## Duplicates and order An event can arrive twice, for example when your endpoint saved it but the answer was lost. Store `webhook-id` and skip ids you have already handled. Events for different messages can arrive in any order; read `data.status` and `data.done_at` rather than relying on arrival order. ## Endpoint rules - HTTPS only, on a public host name (no IP literals, localhost or private networks). - The secret can be replaced on the Webhooks page at any time; calls signed after that use the new secret. - Removing the endpoint stops new events; messages that already finished keep their status for polling. --- # Balance and cost estimates Read the account balance with GET /v1/balance and price a message or batch without sending it with POST /v1/estimates. `GET /v1/balance` The current balance in USD. ```bash curl https://bulksmsapi.cc/v1/balance \ -H "Authorization: Bearer $BULKSMSAPI_KEY" ``` ```javascript const res = await fetch("https://bulksmsapi.cc/v1/balance", { headers: { Authorization: `Bearer ${process.env.BULKSMSAPI_KEY}` }, }); const { balance, currency } = await res.json(); console.log(balance, currency); ``` ```python import json, os, urllib.request req = urllib.request.Request( "https://bulksmsapi.cc/v1/balance", headers={"Authorization": "Bearer " + os.environ["BULKSMSAPI_KEY"], "User-Agent": "northwind-app/1.0"}, ) with urllib.request.urlopen(req) as res: print(json.load(res)["balance"]) ``` ```php true, CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("BULKSMSAPI_KEY")], ]); echo json_decode(curl_exec($ch), true)["balance"], "\n"; ``` ```go package main import ( "encoding/json" "fmt" "net/http" "os" ) func main() { req, _ := http.NewRequest("GET", "https://bulksmsapi.cc/v1/balance", nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("BULKSMSAPI_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() var b struct{ Balance, Currency string } json.NewDecoder(res.Body).Decode(&b) fmt.Println(b.Balance, b.Currency) } ``` 200 OK: ```json { "object": "balance", "balance": "184.32950", "currency": "USD" } ``` ## Estimates `POST /v1/estimates` Takes the same body as POST /v1/batches and returns what it would cost. Nothing is sent and nothing is charged. ```bash curl https://bulksmsapi.cc/v1/estimates \ -H "Authorization: Bearer $BULKSMSAPI_KEY" \ -H "Content-Type: application/json" \ -d '{"text": "Doors open at 18:00 tonight.", "to": ["+14155550123", "+447400123456", "+447400123456", "12"]}' ``` 200 OK: ```json { "object": "estimate", "recipients": 2, "set_aside": [ { "to": "+447400123456", "reason": "duplicate" }, { "to": "12", "reason": "invalid_number" } ], "encoding": "GSM-7", "segments": 2, "max_segments": 1, "cost": "0.01294", "currency": "USD", "by_country": [ { "country": "US", "recipients": 1, "segments": 1, "cost": "0.00507" }, { "country": "GB", "recipients": 1, "segments": 1, "cost": "0.00787" } ] } ``` ## Running low A request that costs more than the balance answers `402 top_up_required` with both amounts in the message, and nothing is sent. Check the balance before a large batch, or estimate first, and top up in the console. --- # Pricing API: SMS prices by country Read the price per SMS segment for every country with GET /v1/pricing, or for one country with GET /v1/pricing/{country}. Public, no key needed. `GET /v1/pricing` Every supported country with its price per segment. Public: no API key. `GET /v1/pricing/{country}` One country by ISO 3166 code (DE, US, BR). Unknown codes answer 404 unsupported_country. ```bash # Public: no key needed curl https://bulksmsapi.cc/v1/pricing/DE ``` ```javascript const res = await fetch("https://bulksmsapi.cc/v1/pricing"); const { data } = await res.json(); const de = data.find((row) => row.country === "DE"); console.log(data.length, de.price); ``` ```python import json, urllib.request req = urllib.request.Request("https://bulksmsapi.cc/v1/pricing", headers={"User-Agent": "northwind-app/1.0"}) with urllib.request.urlopen(req) as res: rows = json.load(res)["data"] print(len(rows), next(r["price"] for r in rows if r["country"] == "DE")) ``` ```php true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("BULKSMSAPI_KEY"), "Content-Type: application/json", ], CURLOPT_POSTFIELDS => json_encode([ "to" => "+14155550123", "text" => "Your Northwind code is 482913. It expires in 10 minutes.", "reference" => "login-7781", ]), ]); $message = json_decode(curl_exec($ch), true); echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $message["id"], " ", $message["status"], "\n"; ``` ```go // Go 1.20 or newer, standard library only package main import ( "bytes" "encoding/json" "fmt" "net/http" "os" ) func main() { body, _ := json.Marshal(map[string]any{ "to": "+14155550123", "text": "Your Northwind code is 482913. It expires in 10 minutes.", "reference": "login-7781", }) req, _ := http.NewRequest("POST", "https://bulksmsapi.cc/v1/messages", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+os.Getenv("BULKSMSAPI_KEY")) req.Header.Set("Content-Type", "application/json") res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() var message map[string]any json.NewDecoder(res.Body).Decode(&message) fmt.Println(res.StatusCode, message["id"], message["status"], message["price"]) } ``` ## Send a batch ```bash curl https://bulksmsapi.cc/v1/batches \ -H "Authorization: Bearer $BULKSMSAPI_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: spring-sale-0412" \ -d '{ "name": "Spring sale", "text": "Spring sale at Northwind: 20% off until Sunday. Opt out: northwind.example/sms", "to": [ "+14155550123", "+447400123456", "+4915123456789" ] }' ``` ```javascript const res = await fetch("https://bulksmsapi.cc/v1/batches", { method: "POST", headers: { Authorization: `Bearer ${process.env.BULKSMSAPI_KEY}`, "Content-Type": "application/json", "Idempotency-Key": "spring-sale-0412", }, body: JSON.stringify({ name: "Spring sale", text: "Spring sale at Northwind: 20% off until Sunday. Opt out: northwind.example/sms", to: ["+14155550123", "+447400123456", "+4915123456789"], }), }); const batch = await res.json(); console.log(res.status, batch.id, batch.recipients, batch.cost); ``` ```python import json, os, urllib.request req = urllib.request.Request( "https://bulksmsapi.cc/v1/batches", data=json.dumps({ "name": "Spring sale", "text": "Spring sale at Northwind: 20% off until Sunday. Opt out: northwind.example/sms", "to": ["+14155550123", "+447400123456", "+4915123456789"], }).encode(), headers={ "Authorization": "Bearer " + os.environ["BULKSMSAPI_KEY"], "Content-Type": "application/json", "Idempotency-Key": "spring-sale-0412", "User-Agent": "northwind-app/1.0", }, method="POST", ) with urllib.request.urlopen(req) as res: batch = json.load(res) print(res.status, batch["id"], batch["recipients"], batch["cost"]) ``` ```php true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("BULKSMSAPI_KEY"), "Content-Type: application/json", "Idempotency-Key: spring-sale-0412", ], CURLOPT_POSTFIELDS => json_encode([ "name" => "Spring sale", "text" => "Spring sale at Northwind: 20% off until Sunday. Opt out: northwind.example/sms", "to" => ["+14155550123", "+447400123456", "+4915123456789"], ]), ]); $batch = json_decode(curl_exec($ch), true); echo $batch["id"], " ", $batch["recipients"], " ", $batch["cost"], "\n"; ``` ```go package main import ( "bytes" "encoding/json" "fmt" "net/http" "os" ) func main() { body, _ := json.Marshal(map[string]any{ "name": "Spring sale", "text": "Spring sale at Northwind: 20% off until Sunday. Opt out: northwind.example/sms", "to": []string{"+14155550123", "+447400123456", "+4915123456789"}, }) req, _ := http.NewRequest("POST", "https://bulksmsapi.cc/v1/batches", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+os.Getenv("BULKSMSAPI_KEY")) req.Header.Set("Content-Type", "application/json") req.Header.Set("Idempotency-Key", "spring-sale-0412") res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() var batch map[string]any json.NewDecoder(res.Body).Decode(&batch) fmt.Println(res.StatusCode, batch["id"], batch["recipients"], batch["cost"]) } ``` ## Read a message status ```bash curl https://bulksmsapi.cc/v1/messages/msg_8F3K2Q9TZ1MX \ -H "Authorization: Bearer $BULKSMSAPI_KEY" ``` ```javascript const res = await fetch("https://bulksmsapi.cc/v1/messages/msg_8F3K2Q9TZ1MX", { headers: { Authorization: `Bearer ${process.env.BULKSMSAPI_KEY}` }, }); const message = await res.json(); console.log(message.status, message.error); ``` ```python import json, os, urllib.request req = urllib.request.Request( "https://bulksmsapi.cc/v1/messages/msg_8F3K2Q9TZ1MX", headers={"Authorization": "Bearer " + os.environ["BULKSMSAPI_KEY"], "User-Agent": "northwind-app/1.0"}, ) with urllib.request.urlopen(req) as res: message = json.load(res) print(message["status"], message["error"]) ``` ```php true, CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("BULKSMSAPI_KEY")], ]); $message = json_decode(curl_exec($ch), true); echo $message["status"], "\n"; ``` ```go package main import ( "encoding/json" "fmt" "net/http" "os" ) func main() { req, _ := http.NewRequest("GET", "https://bulksmsapi.cc/v1/messages/msg_8F3K2Q9TZ1MX", nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("BULKSMSAPI_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() var message map[string]any json.NewDecoder(res.Body).Decode(&message) fmt.Println(message["status"], message["error"]) } ``` ## Check the balance ```bash curl https://bulksmsapi.cc/v1/balance \ -H "Authorization: Bearer $BULKSMSAPI_KEY" ``` ```javascript const res = await fetch("https://bulksmsapi.cc/v1/balance", { headers: { Authorization: `Bearer ${process.env.BULKSMSAPI_KEY}` }, }); const { balance, currency } = await res.json(); console.log(balance, currency); ``` ```python import json, os, urllib.request req = urllib.request.Request( "https://bulksmsapi.cc/v1/balance", headers={"Authorization": "Bearer " + os.environ["BULKSMSAPI_KEY"], "User-Agent": "northwind-app/1.0"}, ) with urllib.request.urlopen(req) as res: print(json.load(res)["balance"]) ``` ```php true, CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("BULKSMSAPI_KEY")], ]); echo json_decode(curl_exec($ch), true)["balance"], "\n"; ``` ```go package main import ( "encoding/json" "fmt" "net/http" "os" ) func main() { req, _ := http.NewRequest("GET", "https://bulksmsapi.cc/v1/balance", nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("BULKSMSAPI_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() var b struct{ Balance, Currency string } json.NewDecoder(res.Body).Decode(&b) fmt.Println(b.Balance, b.Currency) } ``` ## Read prices ```bash # Public: no key needed curl https://bulksmsapi.cc/v1/pricing/DE ``` ```javascript const res = await fetch("https://bulksmsapi.cc/v1/pricing"); const { data } = await res.json(); const de = data.find((row) => row.country === "DE"); console.log(data.length, de.price); ``` ```python import json, urllib.request req = urllib.request.Request("https://bulksmsapi.cc/v1/pricing", headers={"User-Agent": "northwind-app/1.0"}) with urllib.request.urlopen(req) as res: rows = json.load(res)["data"] print(len(rows), next(r["price"] for r in rows if r["country"] == "DE")) ``` ```php { const id = req.header("webhook-id"); const ts = req.header("webhook-timestamp"); const key = Buffer.from(secret.slice("whsec_".length), "base64url"); const expected = crypto.createHmac("sha256", key).update(`${id}.${ts}.${req.body}`).digest("base64"); const sent = (req.header("webhook-signature") || "").split(" ").map((s) => s.replace(/^v1,/, "")); const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300; if (!fresh || !sent.some((s) => s.length === expected.length && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)))) { return res.status(401).end(); } const event = JSON.parse(req.body); console.log(event.type, event.data.id, event.data.status); res.status(204).end(); }); app.listen(3000); ``` ```python # Flask: check the signature on the raw body before parsing it import base64, hashlib, hmac, json, os, time from flask import Flask, abort, request app = Flask(__name__) SECRET = os.environ["BULKSMSAPI_WEBHOOK_SECRET"] # whsec_... @app.post("/hooks/sms") def sms_hook(): msg_id = request.headers.get("webhook-id", "") ts = request.headers.get("webhook-timestamp", "0") raw = request.get_data() key = base64.urlsafe_b64decode(SECRET[len("whsec_"):] + "==") signed = msg_id.encode() + b"." + ts.encode() + b"." + raw expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode() sent = [s.split(",", 1)[-1] for s in request.headers.get("webhook-signature", "").split()] if abs(time.time() - int(ts)) > 300 or not any(hmac.compare_digest(s, expected) for s in sent): abort(401) event = json.loads(raw) print(event["type"], event["data"]["id"], event["data"]["status"]) return "", 204 ``` ```php 300) { http_response_code(401); exit; } $event = json_decode($raw, true); error_log($event["type"] . " " . $event["data"]["id"]); http_response_code(204); ``` ```go package main import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "io" "log" "math" "net/http" "os" "strconv" "strings" "time" ) func smsHook(w http.ResponseWriter, r *http.Request) { secret := os.Getenv("BULKSMSAPI_WEBHOOK_SECRET") // whsec_... key, _ := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_")) raw, _ := io.ReadAll(r.Body) id, ts := r.Header.Get("webhook-id"), r.Header.Get("webhook-timestamp") mac := hmac.New(sha256.New, key) mac.Write([]byte(id + "." + ts + "." + string(raw))) expected := base64.StdEncoding.EncodeToString(mac.Sum(nil)) sec, _ := strconv.ParseInt(ts, 10, 64) ok := false for _, part := range strings.Fields(r.Header.Get("webhook-signature")) { sig := part[strings.Index(part, ",")+1:] if hmac.Equal([]byte(sig), []byte(expected)) { ok = true } } if !ok || math.Abs(float64(time.Now().Unix()-sec)) > 300 { w.WriteHeader(http.StatusUnauthorized) return } var event struct { Type string Data map[string]any } json.Unmarshal(raw, &event) log.Println(event.Type, event.Data["id"], event.Data["status"]) w.WriteHeader(http.StatusNoContent) } func main() { http.HandleFunc("/hooks/sms", smsHook) log.Fatal(http.ListenAndServe(":3000", nil)) } ```