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:

  1. Endpoint permanente: desde el portal se da de alta una vez, eligiendo el servicio (sms, email, voice, etc.) y los event_types que se quieren escuchar (o todos por defecto). Cada endpoint trae su propio secreto que se usa para firmar todas sus entregas.
  2. URL por-request: algunos endpoints de la API aceptan un campo (p.ej. webhookUrl en 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 a tu endpoint
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.

CampoTipoSignificado
iduuidIdentificador del evento lógico.
typestringTipo dotted, p.ej. sms.delivered, voice.answered, numberinsight.batch.completed.
versionnumberVersión del sobre. Hoy 1; futuros cambios incompatibles bumpean este número.
servicestringServicio del lado emisor (sms, email, voice, numberinsight, …).
accountIdnumberCuenta a la que pertenece el evento.
occurredAtISO-8601Cuándo ocurrió el evento (con timezone).
dataobjetoPayload específico del type.

Headers de cada entrega

HeaderSignificado
X-Hablame-Event-TypeEl type del evento: útil para enrutar antes de parsear el JSON.
X-Hablame-Delivery-IdIdentificador único de esta entrega (UUID). Distinto entre reintentos del mismo evento.
X-Hablame-TimestampEpoch en segundos al momento de firmar: se usa para detectar replays.
X-Hablame-SignatureFirma HMAC del cuerpo. Formato sha256=<hex>.
Idempotency-KeyIgual al delivery_id. Sirve para que tu lado deduplique entre reintentos del mismo evento.
Content-TypeSiempre 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:

  1. Lee el cuerpo crudo (sin re-serializar JSON: un cambio de espacios invalida la firma).
  2. Lee X-Hablame-Timestamp y rechaza si está fuera de tu ventana de tolerancia (5 minutos es razonable).
  3. Calcula hex(HMAC-SHA256(secret, timestamp + "." + body)).
  4. Compara con la parte después de sha256= usando una comparación de tiempo constante (en PHP, hash_equals; en Python, hmac.compare_digest).
  5. Si la rotación está en curso, también acepta el secreto anterior durante la ventana de rotación.
PHP
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));
}
Python
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 timestamp antiguo. 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/https reales).
  • 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.