Notificaciones por webhook
Deja de consultar en bucle. Enviamos el código SMS a tu servidor en cuanto llega, firmado y con reintentos.
Por qué webhooks
Consultar en bucle gasta peticiones y añade retraso. Un webhook llega a tu servidor en el mismo segundo en que aparece el código.
- Sin bucle de consulta: tu cuota diaria de peticiones se dedica al trabajo real y no a comprobar estados.
- Menos latencia: el código llega a tu sistema en cuanto lo recibimos, no en tu siguiente consulta.
- También los eventos finales: las cancelaciones y las expiraciones llegan con el estado del reembolso, así tu contabilidad se mantiene al día.
Configuración
Registra una URL por cuenta con tu clave de API. El secreto de firma se devuelve una sola vez.
curl -X POST https://smsbulk.net/api/users/webhook \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://your-app.com/smsbulk/webhook"}'Respuesta
{
"id": "wep_2f8c1a9d",
"url": "https://your-app.com/smsbulk/webhook",
"sourceFilter": "API",
"secret": "whsec_5f3a9c1e8b2d47a6903c5e1f7d8b4a26"
}Guarda el secreto ahora
El secreto en claro solo aparece en esta respuesta. Las lecturas posteriores devuelven un valor enmascarado. Si lo pierdes, renuévalo desde el panel o con el endpoint de rotación.
Requisitos de la URL
- HTTPS en el puerto 443. Se rechazan HTTP simple y otros puertos.
- Una dirección pública. Los rangos privados, loopback y las direcciones de metadatos de nube se rechazan al registrar y de nuevo en cada entrega.
- Sin redirecciones. Una respuesta 3xx no se sigue y cuenta como intento fallido.
| Método | Ruta | Propósito |
|---|---|---|
| POST | /api/users/webhook | Registra o reemplaza el endpoint y devuelve el secreto una vez |
| GET | /api/users/webhook | Lee el endpoint actual, el secreto va enmascarado |
| PATCH | /api/users/webhook | Cambia la URL o el filtro de canal, o reactiva un endpoint desactivado |
| POST | /api/users/webhook/rotate | Renueva el secreto de firma con una ventana de transición |
| POST | /api/users/webhook/test | Envía un evento de prueba sin datos de activación |
| DELETE | /api/users/webhook | Elimina el endpoint y detiene todas las notificaciones |
| GET | /api/users/webhook/deliveries | Últimos 20 intentos de entrega, solo metadatos |
Eventos
Tres eventos, todos sobre una misma activación.
| Evento | Cuándo se envía |
|---|---|
| activation.code_received | Cuando llega el código SMS. Es el evento sobre el que actúan casi todas las integraciones. |
| activation.cancelled | Cuando la activación se cancela. El cuerpo indica el motivo y si se devolvió el saldo. |
| activation.expired | Cuando la activación agota su tiempo sin código. El cuerpo indica el estado del reembolso. |
Filtro de canal
Por defecto solo los pedidos hechos por API generan notificación. Pon sourceFilter en null si también quieres los pedidos que haces desde el panel.
Petición y cuerpo
Cada entrega es un POST con cuerpo JSON. Los campos se eligen uno a uno: el enrutamiento interno y los detalles de coste nunca aparecen.
Forma de la petición
POST /smsbulk/webhook HTTP/1.1
Content-Type: application/json
User-Agent: SMSBulk-Webhook/1
X-SMSBulk-Signature: t=1785312000,v1=8f2a...c41d
X-SMSBulk-Event-Id: evt_del_9f2c1a
X-SMSBulk-Event-Type: activation.code_received
X-SMSBulk-Delivery-Attempt: 1activation.code_received
{
"id": "evt_del_9f2c1a",
"type": "activation.code_received",
"created": 1785312000,
"data": {
"activation_id": "cms2u6x80d1twmkudp2cfbqo4",
"status": "RECEIVED",
"phone_number": "447700900123",
"service": "wa",
"country": "GB",
"code": "483920",
"price": "0.42",
"currency": "USD",
"source": "API",
"created_at": "2026-08-04T09:12:00.000Z",
"sms_text": "Your code is 483920",
"received_at": "2026-08-04T09:12:34.000Z"
}
}activation.cancelled y activation.expired
{
"id": "evt_del_7b31de",
"type": "activation.cancelled",
"created": 1785312600,
"data": {
"activation_id": "cms2u6x80d1twmkudp2cfbqo4",
"status": "CANCELLED",
"phone_number": "447700900123",
"service": "wa",
"country": "GB",
"code": null,
"price": "0.42",
"currency": "USD",
"source": "API",
"created_at": "2026-08-04T09:12:00.000Z",
"reason": "user_cancel",
"refunded": true,
"refund_amount": "0.42"
}
}En versiones futuras pueden aparecer campos nuevos. Ignora los campos desconocidos en lugar de fallar con ellos.
Verificar la firma
Cada petición lleva una firma HMAC SHA256 construida con la marca de tiempo y el cuerpo en bruto.
X-SMSBulk-Signature: t=<unix_seconds>,v1=<hex_hmac_sha256>
signed_string = "<t>.<raw_request_body>"Verifica los bytes en bruto
Calcula la firma sobre el cuerpo exacto que recibiste. Si analizas el JSON y lo vuelves a serializar, cambian el orden de las claves y los espacios, y la firma no coincidirá.
Puede haber más de un v1
Durante una rotación de secreto la cabecera lleva dos valores v1. Recógelos en una lista, no en un diccionario: un diccionario solo guarda el último y el secreto nuevo quedaría sin verificar en silencio.
Node.js
const crypto = require('crypto');
function verify(rawBody, header, secret) {
const pairs = header.split(',').map((p) => {
const i = p.indexOf('=');
return [p.slice(0, i), p.slice(i + 1)];
});
const t = pairs.find(([k]) => k === 't')?.[1];
// During a rotate window more than one signature arrives: collect them all.
const signatures = pairs.filter(([k]) => k === 'v1').map(([, v]) => v);
if (!t || signatures.length === 0) return false;
// Replay window: reject anything older than 5 minutes.
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
return signatures.some(
(s) =>
s.length === expected.length &&
crypto.timingSafeEqual(
Buffer.from(s, 'hex'),
Buffer.from(expected, 'hex'),
),
);
}Python
import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
pairs = [p.split("=", 1) for p in header.split(",")]
t = next((v for k, v in pairs if k == "t"), None)
# During a rotate window more than one signature arrives: collect them all.
signatures = [v for k, v in pairs if k == "v1"]
if t is None or not signatures:
return False
# Replay window: reject anything older than 5 minutes.
if abs(time.time() - int(t)) > 300:
return False
expected = hmac.new(
secret.encode(),
f"{t}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
return any(hmac.compare_digest(s, expected) for s in signatures)La marca de tiempo forma parte de la cadena firmada, así que puedes rechazar lo que tenga más de unos minutos. Compara las firmas en tiempo constante.
Respuesta y reintentos
Responde rápido. Esperamos 10 segundos por la respuesta.
Haz
- Devuelve 2xx en cuanto hayas guardado el evento.
- Encola el trabajo pesado y responde primero.
- Trata un 4xx propio como un error: detiene los reintentos.
No hagas
- No ejecutes tareas largas antes de responder, la petición expira a los 10 segundos.
- No respondas con 3xx, las redirecciones no se siguen.
- No construyas un cuerpo de respuesta grande, se descarta sin leerlo.
| Intento | Espera previa | activation.code_received | cancelled y expired |
|---|---|---|---|
| 1 | inmediato | ||
| 2 | 10s | ||
| 3 | 60s | ||
| 4 | 5m | ||
| 5 | 15m | ||
| 6 | 30m |
Se reintenta ante errores de red, tiempos de espera, 408, 429 y 5xx. Cualquier otro 4xx detiene la escalera de inmediato. code_received se corta en 4 intentos porque la activación termina mucho antes de que los pasos siguientes sirvan de algo.
Deduplicación
Usa X-SMSBulk-Event-Id como clave de idempotencia. Los reintentos del mismo evento llevan el mismo id.
Al menos una vez, no exactamente una vez
Se crea un registro por activación y tipo de evento, así que los duplicados son raros. Aun así, la recuperación de un trabajo atascado puede entregar el mismo registro otra vez. Guarda el id del evento e ignora un id que ya hayas procesado.
Cortacircuitos
Un endpoint muerto no se llama para siempre.
- Tras 20 entregas fallidas seguidas el endpoint se desactiva.
- Recibes un correo y el panel muestra por qué se desactivó.
- Los eventos creados mientras el endpoint estaba desactivado no se reenvían. Ponte al día con GET /v1/activations.
Rotar el secreto
Cambia el secreto de firma sin cortes.
- El endpoint de rotación devuelve el nuevo secreto una vez.
- Durante la ventana de transición se envían la firma nueva y la anterior.
- Pasa al secreto nuevo a tu ritmo, tras la ventana solo se envía la firma nueva.
Configura tu endpoint
Registra una URL, envía un evento de prueba y sigue la lista de entregas en tu panel.
