Resumen
Usa la API cuando los mensajes son activados por tus propios sistemas: actualizaciones de pedidos de una tienda, códigos de un formulario de inicio de sesión, recordatorios de una herramienta de reservas. Una solicitud a POST /v1/messages con hasta 10,000 números crea una campaña, carga el saldo y pone en cola cada mensaje. Las campañas creadas por la API también aparecen en la consola con sus informes de entrega.
- Todos los montos son en dólares estadounidenses como cadenas con cinco decimales, por ejemplo "0.00534".
- Los números de teléfono son internacionales, con o sin el + inicial; se eliminan espacios y guiones.
- Las horas son ISO 8601 en UTC, por ejemplo 2026-12-01T09:00:00Z.
- Los IDs de mensaje y campaña se ven como MSG-XXXX-XXXX-XXXX y CMP-XXXX-XXXX-XXXX.
Autenticación
Crea una clave API en API en la consola (las claves se activan después de tu primera recarga). Envíala en el encabezado Authorization de cada solicitud. Una clave se ve como bso_api_ seguido de 36 caracteres; se muestra una vez, así que guárdala como secreto en tu servidor. Crear una nueva clave reemplaza la anterior de inmediato.
Authorization: Bearer bso_api_0123456789abcdef0123456789abcdef0123 Content-Type: application/json
Límites
| Solicitudes | 300 por minuto por clave; más responde 429 rate_limited |
|---|---|
| Números por solicitud | 10,000 |
| Texto del mensaje | 2,000 caracteres como están escritos, hasta 10 segmentos |
| Programación | send_at desde 5 minutos hasta 60 días adelante |
| Ritmo de envío | 400 mensajes por minuto por cola |
| Reintentos de webhook | 9 intentos en aproximadamente 22 horas |
Enviar mensajes
POST/v1/messages
Envía un mensaje de texto a uno o más números. El costo total se deduce del saldo cuando se acepta la solicitud; cada mensaje que no se pueda entregar se reembolsa después. La respuesta tiene estado 201 y la campaña con cada mensaje.
| Campo | Tipo | Descripción |
|---|---|---|
toobligatorio | string | string[] | Un número o un array de hasta 10,000 números en formato internacional. Los números repetidos se envían una vez; los ilegibles se listan en skipped. |
textobligatorio | string | El mensaje, hasta 2,000 caracteres y 10 segmentos. El texto GSM-7 tiene 160 caracteres por SMS, Unicode 70. |
send_atopcional | string | Hora ISO 8601 para programar el envío, de 5 minutos a 60 días adelante. Omítelo para enviar ahora. |
opt_outopcional | boolean | true añade tu línea de exclusión voluntaria de la configuración de la consola en una nueva línea al final. |
client_refopcional | string | Tu propia referencia, hasta 64 letras, dígitos y _ . : -. Se devuelve en informes y 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" }
]
}Cotizar un envío
POST/v1/quote
Toma el mismo cuerpo que /v1/messages y devuelve el costo exacto, segmentos y codificación sin enviar ni cobrar nada. Úsalo para mostrar un precio a tus usuarios antes de que confirmen.
{
"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" } ]
}Saldo
GET/v1/balance
{ "balance": "118.43120", "currency": "USD" }Tarifas
GET/v1/rates
GET/v1/rates/{country}
El precio de un segmento SMS para cada destino, o para un código de país ISO de dos letras. Estos dos endpoints son públicos y no requieren clave.
{ "country": "DE", "name": "Germany", "calling_code": "+49", "price": "0.01544", "currency": "USD", "per": "segment" }Estado del mensaje
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"
}Campañas
GET/v1/campaigns/{id}
GET/v1/campaigns/{id}/messages?offset=0&limit=100
El primero devuelve la campaña con sus conteos por estado; el segundo pagina sus mensajes, hasta 1,000 por página. Las campañas enviadas desde la consola se pueden leer de la misma manera con el id mostrado en su página.
Cancelar un envío programado
POST/v1/campaigns/{id}/cancel
Detiene una campaña que se creó con send_at y aún no ha comenzado. Cada mensaje se cancela y el costo total vuelve al saldo. Una campaña que ya se está enviando responde 409 not_scheduled.
Webhooks de entrega
Añade una URL HTTPS en API en la consola para recibir cada estado final (entregado o fallido) como una solicitud POST. Responde con cualquier estado 2xx en 8 segundos. Otras respuestas y timeouts se reintentan después de 20 segundos, 1, 5 y 15 minutos, luego 1, 3, 6 y 12 horas, 9 intentos en total. El mismo evento puede llegar dos veces, así que usa event_id para procesarlo una vez.
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"
}
}Verificar la firma
El encabezado contiene el tiempo Unix t y la firma v1 = HMAC-SHA256 de la cadena "t.raw_body" con tu secreto de webhook, en hex. Calcúlalo sobre el cuerpo sin procesar antes de analizar el JSON, compara en tiempo constante y rechaza eventos de más de cinco minutos.
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]);
}Estados
| queued | Aceptado y esperando salir de la cola |
|---|---|
| submitted | Entregado a la red de destino |
| delivered | Confirmado por el dispositivo (final) |
| failed | No se pudo entregar; el precio se reembolsa (final) |
| canceled | Parte de una campaña programada cancelada antes del envío (final) |
El estado de la campaña es scheduled, sending, complete o canceled.
Errores
Los errores responden con un estado HTTP y un cuerpo JSON: { "error": { "code": "insufficient_balance", "message": "..." } }. No se cobra nada cuando una solicitud responde con un error.
| HTTP | Código | Significado |
|---|---|---|
| 400 | invalid_request | Falta un campo o no es válido; el mensaje indica cuál. |
| 400 | invalid_json | El cuerpo no es JSON. |
| 400 | no_valid_numbers | No se pudieron leer ninguno de los números. |
| 401 | unauthorized | La clave falta, está malformada o ha sido reemplazada. |
| 402 | insufficient_balance | El saldo no cubre el costo; recarga y envía de nuevo. |
| 404 | not_found | ID o endpoint desconocido. |
| 409 | not_scheduled | Solo se puede cancelar una campaña programada que no haya comenzado. |
| 413 | too_many_recipients | Más de 10,000 números en una solicitud. |
| 429 | rate_limited | Más de 300 solicitudes en un minuto. |
| 500 | server_error | Reintenta la solicitud; no se cobró nada. |