Aperçu
Utilisez l'API lorsque les messages sont déclenchés par vos propres systèmes : mises à jour de commande d'une boutique, codes d'un formulaire de connexion, rappels d'un outil de réservation. Une requête POST /v1/messages avec jusqu'à 10,000 numéros crée une campagne, débite le solde et met chaque message en file d'attente. Les campagnes créées par l'API apparaissent aussi dans la console avec leurs rapports de remise.
- Tous les montants sont en dollars US sous forme de chaînes avec cinq décimales, par exemple "0.00534".
- Les numéros de téléphone sont internationaux, avec ou sans le + initial ; les espaces et les tirets sont supprimés.
- Les heures sont au format ISO 8601 en UTC, par exemple 2026-12-01T09:00:00Z.
- Les identifiants de message et de campagne ressemblent à MSG-XXXX-XXXX-XXXX et CMP-XXXX-XXXX-XXXX.
Authentification
Créez une clé API sous API dans la console (les clés sont accessibles après votre premier rechargement). Envoyez-la dans l'en-tête Authorization de chaque requête. Une clé ressemble à bso_api_ suivi de 36 caractères ; elle n'est affichée qu'une fois, alors stockez-la comme secret sur votre serveur. Créer une nouvelle clé remplace immédiatement la précédente.
Authorization: Bearer bso_api_0123456789abcdef0123456789abcdef0123 Content-Type: application/json
Limites
| Requêtes | 300 par minute par clé ; au-delà, réponse 429 rate_limited |
|---|---|
| Numéros par requête | 10,000 |
| Texte du message | 2,000 caractères tels qu'écrits, jusqu'à 10 segments |
| Planification | send_at de 5 minutes à 60 jours à l'avance |
| Cadence d'envoi | 400 messages par minute par file |
| Nouvelles tentatives de webhook | 9 tentatives sur environ 22 heures |
Envoyer des messages
POST/v1/messages
Envoie un texte à un ou plusieurs numéros. Le coût total est prélevé du solde lorsque la demande est acceptée ; chaque message qui ne peut être livré est remboursé ultérieurement. La réponse a le statut 201 et la campagne avec chaque message.
| Champ | Type | Description |
|---|---|---|
toobligatoire | string | string[] | Un numéro ou un tableau de jusqu'à 10,000 numéros au format international. Les numéros répétés sont envoyés une fois ; les numéros illisibles sont listés dans skipped. |
textobligatoire | string | Le message, jusqu'à 2,000 caractères et 10 segments. Le texte GSM-7 contient 160 caractères par SMS, Unicode 70. |
send_atoptionnel | string | Heure ISO 8601 pour planifier l'envoi, de 5 minutes à 60 jours à l'avance. Omettez pour envoyer maintenant. |
opt_outoptionnel | boolean | true ajoute votre mention de désinscription des paramètres de la console sur une nouvelle ligne à la fin. |
client_refoptionnel | string | Votre propre référence, jusqu'à 64 lettres, chiffres et _ . : -. Elle est renvoyée dans les rapports et les 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" }
]
}Devis d'envoi
POST/v1/quote
Prend le même corps que /v1/messages et renvoie le coût exact, les segments et l'encodage sans envoyer ni facturer quoi que ce soit. Utilisez-le pour afficher un prix à vos propres utilisateurs avant qu'ils ne confirment.
{
"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" } ]
}Solde
GET/v1/balance
{ "balance": "118.43120", "currency": "USD" }Tarifs
GET/v1/rates
GET/v1/rates/{country}
Le prix d'un segment SMS pour chaque destination, ou pour un code pays ISO à deux lettres. Ces deux endpoints sont publics et ne nécessitent pas de clé.
{ "country": "DE", "name": "Germany", "calling_code": "+49", "price": "0.01544", "currency": "USD", "per": "segment" }Statut du message
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"
}Campagnes
GET/v1/campaigns/{id}
GET/v1/campaigns/{id}/messages?offset=0&limit=100
Le premier renvoie la campagne avec ses comptes par statut ; le second pagine ses messages, jusqu'à 1 000 par page. Les campagnes envoyées depuis la console peuvent être lues de la même manière avec l'id affiché sur leur page.
Annuler un envoi programmé
POST/v1/campaigns/{id}/cancel
Arrête une campagne créée avec send_at et qui n'a pas encore commencé. Chaque message est annulé et le coût total est retourné au solde. Une campagne déjà en cours d'envoi répond 409 not_scheduled.
Webhooks de livraison
Ajoutez une URL HTTPS sous API dans la console pour recevoir chaque statut final (livré ou échoué) sous forme de requête POST. Répondez avec n'importe quel statut 2xx dans les 8 secondes. Les autres réponses et les timeouts sont réessayés après 20 secondes, 1, 5 et 15 minutes, puis 1, 3, 6 et 12 heures, 9 tentatives au total. Le même événement peut arriver deux fois, utilisez donc event_id pour le traiter une fois.
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"
}
}Vérifiez la signature
L'en-tête contient le temps Unix t et la signature v1 = HMAC-SHA256 de la chaîne "t.raw_body" avec votre secret webhook, en hexadécimal. Calculez-la sur le corps brut avant d'analyser le JSON, comparez en temps constant et rejetez les événements de plus de cinq 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]);
}Statuts
| queued | Accepté et en attente de quitter la file |
|---|---|
| submitted | Remis au réseau de destination |
| delivered | Confirmé par le terminal (final) |
| failed | N'a pas pu être livré ; le prix est remboursé (final) |
| canceled | Partie d'une campagne planifiée annulée avant l'envoi (final) |
Le statut de la campagne est scheduled, sending, complete ou canceled.
Erreurs
Les erreurs répondent avec un statut HTTP et un corps JSON : { "error": { "code": "insufficient_balance", "message": "..." } }. Rien n'est facturé lorsqu'une demande répond avec une erreur.
| HTTP | Code | Signification |
|---|---|---|
| 400 | invalid_request | Un champ est manquant ou non valide ; le message indique lequel. |
| 400 | invalid_json | Le corps n'est pas du JSON. |
| 400 | no_valid_numbers | Aucun des numéros n'a pu être lu. |
| 401 | unauthorized | La clé est manquante, mal formée ou remplacée. |
| 402 | insufficient_balance | Le solde ne couvre pas le coût ; rechargez et renvoyez. |
| 404 | not_found | ID ou endpoint inconnu. |
| 409 | not_scheduled | Seule une campagne planifiée qui n'a pas démarré peut être annulée. |
| 413 | too_many_recipients | Plus de 10,000 numéros dans une seule requête. |
| 429 | rate_limited | Plus de 300 requêtes par minute. |
| 500 | server_error | Réessayez la requête ; rien n'a été facturé. |