Webhooks
Cuando algo interesante pasa con tus servicios (un SMS se
entregó, un correo se abrió, una llamada se contestó, un lote de
Number Insight terminó), Hablame te lo notifica con un
POST a una URL que tú controlas. Esta página
documenta el contrato: el sobre del evento, los headers de firma,
cómo verificarlos y qué hacer ante reintentos.
Cómo se da de alta un endpoint
La gestión de endpoints (alta, baja, rotación de secreto, historial de entregas) se hace desde el portal de cloud, no desde la API pública: el sistema solo te envía webhooks, no necesitas llamar nada para configurarlos. Dos modalidades:
-
Endpoint permanente: desde el portal se da de
alta una vez, eligiendo el servicio (sms, email, voice, etc.) y
los
event_typesque se quieren escuchar (o todos por defecto). Cada endpoint trae su propio secreto que se usa para firmar todas sus entregas. -
URL por-request: algunos endpoints de la API
aceptan un campo (p.ej.
webhookUrlen el batch de Number Insight) con una URL ad-hoc. Esa URL recibe el evento, firmada con el secreto por cuenta (compartido por todas las URLs por-request).
Contrato HTTP
Cada entrega es un POST con cuerpo JSON. Devuelve
cualquier 2xx en menos de unos segundos para
confirmar la recepción; cualquier otra cosa (4xx no-2xx, 5xx,
timeout) se considera fallo y entra en reintentos.
POST /webhooks/hablame HTTP/1.1 Host: example.com Content-Type: application/json User-Agent: Hablame-Webhooks/1 X-Hablame-Event-Type: sms.delivered X-Hablame-Delivery-Id: b85b3d50-7c44-4ed8-aac6-3c2b6a4fe1aa X-Hablame-Timestamp: 1781832862 X-Hablame-Signature: sha256=8c4f3c2b7a91… Idempotency-Key: b85b3d50-7c44-4ed8-aac6-3c2b6a4fe1aa { "id": "…", "type": "sms.delivered", "version": 1, … }
El sobre del evento
Todos los eventos tienen la misma forma, agnóstica del servicio.
Un servicio nuevo agrega type nuevos sin cambiar la
estructura.
| Campo | Tipo | Significado |
|---|---|---|
id | uuid | Identificador del evento lógico. |
type | string | Tipo dotted, p.ej. sms.delivered, voice.answered, numberinsight.batch.completed. |
version | number | Versión del sobre. Hoy 1; futuros cambios incompatibles bumpean este número. |
service | string | Servicio del lado emisor (sms, email, voice, numberinsight, …). |
accountId | number | Cuenta a la que pertenece el evento. |
occurredAt | ISO-8601 | Cuándo ocurrió el evento (con timezone). |
data | objeto | Payload específico del type. |
Headers de cada entrega
| Header | Significado |
|---|---|
X-Hablame-Event-Type | El type del evento: útil para enrutar antes de parsear el JSON. |
X-Hablame-Delivery-Id | Identificador único de esta entrega (UUID). Distinto entre reintentos del mismo evento. |
X-Hablame-Timestamp | Epoch en segundos al momento de firmar: se usa para detectar replays. |
X-Hablame-Signature | Firma HMAC del cuerpo. Formato sha256=<hex>. |
Idempotency-Key | Igual al delivery_id. Sirve para que tu lado deduplique entre reintentos del mismo evento. |
Content-Type | Siempre application/json. |
Verificar la firma HMAC
Calculamos la firma con HMAC-SHA256 sobre la cadena
"{timestamp}.{cuerpo_crudo}", usando el secreto del
endpoint (o el de cuenta para URLs por-request). El header trae
sha256=<hex>.
Para verificar:
- Lee el cuerpo crudo (sin re-serializar JSON: un cambio de espacios invalida la firma).
- Lee
X-Hablame-Timestampy rechaza si está fuera de tu ventana de tolerancia (5 minutos es razonable). - Calcula
hex(HMAC-SHA256(secret, timestamp + "." + body)). - Compara con la parte después de
sha256=usando una comparación de tiempo constante (en PHP,hash_equals; en Python,hmac.compare_digest). - Si la rotación está en curso, también acepta el secreto anterior durante la ventana de rotación.
function verifyHablameSignature(string $rawBody, array $headers, string $secret): bool { $sig = $headers['x-hablame-signature'] ?? ''; $ts = (int) ($headers['x-hablame-timestamp'] ?? 0); // Anti-replay: 5 minutos. if (\abs(\time() - $ts) > 300) { return false; } if (!\str_starts_with($sig, 'sha256=')) { return false; } $expected = \hash_hmac('sha256', $ts . '.' . $rawBody, $secret); return \hash_equals($expected, \substr($sig, 7)); }
import hmac, hashlib, time def verify(raw_body: bytes, headers: dict, secret: bytes) -> bool: sig = headers.get('X-Hablame-Signature', '') ts = int(headers.get('X-Hablame-Timestamp', 0)) if abs(time.time() - ts) > 300: return False if not sig.startswith('sha256='): return False msg = f'{ts}.'.encode() + raw_body expected = hmac.new(secret, msg, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, sig[7:])
Idempotencia: qué tienes que asumir
La entrega es at-least-once: ante una caída
intermedia (la red, tu servidor, nuestro worker), reenviamos el
mismo evento con el mismo delivery_id
/Idempotency-Key. Cualquier integración seria debe
deduplicar por ese header: guárdalo en una tabla con UNIQUE y
descarta la segunda inserción.
Si tu handler hace algo no idempotente (un cargo, un mensaje en un chat), la dedup tiene que envolver eso. No basta con contestar 200 y procesar después: si tu servicio se cae entre el 200 y el commit, lo pierdes.
Reintentos y backoff
Si una entrega falla, la reencolamos con backoff exponencial +
jitter (~5 s, 10 s, 20 s, … con techo de 1 h) hasta unos 12
intentos. Después de eso, la entrega queda como failed
y va a una DLQ que monitoreamos. Los reintentos conservan
prioridad: los eventos urgentes (voice.*) van a una
cola dedicada para entrega quasi-inmediata.
Tras varios fallos consecutivos sobre el mismo endpoint, lo deshabilitamos automáticamente (circuit breaker) para no gastar workers en una URL muerta. Un éxito posterior lo reabre.
Seguridad
- Verifica la firma siempre. Sin verificación, cualquiera puede hacerle POST a tu URL pretendiendo ser Hablame.
-
Valida el timestamp. Una firma vieja replayada
tiene firma válida pero
timestampantiguo. Una ventana de ±5 minutos elimina el reuso. - Toma el secreto del entorno, no del repo. Rótalo periódicamente desde el portal. Mientras dura la rotación, aceptamos firmar tanto con el secreto nuevo como con el anterior; tu verificador puede hacer lo mismo (probar ambos).
-
HTTPS obligatorio. Validamos la URL antes de
entregar (anti-SSRF: bloqueamos loopback y rangos privados, solo
http/httpsreales). - Responde rápido. Si tu handler tarda en procesar, encola internamente y devuelve 2xx inmediato: si no, la entrega cae por timeout y entra a reintentos.