Ir al contenido

El primer webhook

Un webhook es la forma más simple de enterarte de lo que pasa en tus instancias: el motor hace un POST firmado a tu URL por cada evento. Esta página amplía el paso 5 del Inicio rápido: qué pedirle al operador, el formato exacto de la firma, un receptor completo y qué hace el motor con cada respuesta tuya. Los reintentos, el orden, el replay y la rotación en detalle están en Webhooks.

El motor solo entrega a hosts de una lista de destinos permitidos, para que una key robada no pueda mandar tus eventos a cualquier lado. La lista es la suma de dos:

  • la global, en Ajustes → Webhooks → Destinos permitidos, para todas las apps;
  • la de tu app, en su ficha del panel (hasta 50 entradas; solo un superadmin la cambia).

Cada entrada es un host (crm-interno) o un host con puerto (crm-interno:3000). Sin puerto, vale cualquiera. Además, siempre:

  • la URL es http:// o https://, sin usuario ni contraseña y sin #fragmento;
  • los servicios de metadatos de la nube (169.254.0.0/16, metadata.google.internal y parecidos) están bloqueados, aunque estén en la lista;
  • localhost y 127.0.0.1 solo valen si la lista nombra esa IP literal (para pruebas);
  • el motor no sigue redirecciones, y vuelve a validar la IP real al conectar: un DNS que cambia no se cuela.

POST /v1/subscriptions (scope subscriptions:manage):

Campo Qué es
nombre De 1 a 120 caracteres.
url Tu receptor. Su host tiene que estar permitido.
instancias Opcional (hasta 200). Vacío: todas las instancias de la app. Una key restringida tiene que nombrarlas.
tipos Opcional (hasta 50). Vacío: todos los tipos. Un tipo que no existe es 422 en body.tipos.
ordenado Por omisión, true: cada instancia se entrega en orden de seq, y un evento espera a que salga el anterior de su instancia.

La respuesta 201 trae suscripcion y secreto (key_id y valor). El secreto se muestra una sola vez: guárdalo sellado en tu app antes de hacer nada más. Si lo pierdes, rótalo con POST /v1/subscriptions/{id}/rotate.

201 Created
{
"suscripcion": {
"id": "0f3b9d22-0000-4000-8000-0000000000d1",
"nombre": "CRM principal",
"url": "https://crm.ejemplo.com/webhooks/motor",
"instancias": [],
"tipos": ["message.received", "message.updated", "command.failed"],
"ordenado": true,
"estado": "activa",
"version": 1,
"claves": [{ "…": "…" }],
"pendientes": 0,
"fallidas": 0
},
"secreto": { "key_id": "whk_SECRETO_KEY_ID", "valor": "TU_SECRETO_DE_WEBHOOK" }
}

Dos detalles al crearla:

  • Recibe desde ahora. La suscripción recibe los eventos que pasen desde que existe. Lo anterior lo lees del ledger (GET /v1/instances/{id}/events) o lo pides con un replay.
  • El alta no es idempotente. POST /v1/subscriptions no acepta Idempotency-Key: si la red se corta al crearla, lista las suscripciones (GET /v1/subscriptions) antes de repetir, y borra la que sobre.
Respuesta Causa
422 body.url «…no está en la lista de destinos permitidos…» Falta el host en la lista: pídeselo al operador.
422 body.url «…apunta a una dirección bloqueada…» Metadatos de la nube, o localhost sin la IP literal en la lista.
422 body.url «…no puede llevar usuario ni contraseña» Quita las credenciales de la URL.
422 body.instancias Una instancia no es de la app o queda fuera de tu key.

Cada entrega es un POST con el sobre del evento en el cuerpo y estos encabezados:

Encabezado Qué es
X-MW-Key-Id El key_id del secreto que firmó (whk_…). Durante una rotación, dos, separados por comas.
X-MW-Timestamp Segundos Unix del intento. Cambia en cada reintento.
X-MW-Delivery-Id La entrega. Es el mismo en todos los reintentos de esa entrega.
X-MW-Event-Id El evento. Es el mismo en los reintentos y en un replay: deduplica por este.
X-MW-Signature v1=<hex>. Durante una rotación, dos, en el mismo orden que X-MW-Key-Id.
Content-Type application/json.
User-Agent motor-whatsapp/webhooks.

La firma es un HMAC-SHA256 en hexadecimal, con el valor del secreto completo (con su prefijo whsec_) como clave, sobre esta cadena:

<X-MW-Timestamp> + "." + <X-MW-Delivery-Id> + "." + <cuerpo crudo>

Para verificarla:

  1. Lee el cuerpo crudo, sin parsearlo ni volver a serializarlo: un JSON reformateado no coincide.
  2. Rechaza un timestamp viejo o del futuro con un 408. El SDK de Go tolera 5 minutos; mantén el reloj de tu servidor sincronizado. No uses 401: el motor firma cada intento con su hora, así que un timestamp vencido es un reloj desfasado, y un 401 suspendería la suscripción.
  3. Para cada key_id que conoces, calcula el HMAC y compáralo en tiempo constante con la firma de la misma posición. Basta con que una coincida.
  4. Recién entonces parsea el JSON.

Durante la ventana de una rotación (60 minutos de fábrica) cada entrega trae la firma con el secreto nuevo y con el anterior: guarda los dos key_id y acepta cualquiera hasta que pase la ventana.

Tu receptor tiene que verificar la firma, guardar el evento una sola vez (deduplicando por event_id) y responder 2xx solo después de guardarlo. El motor entrega «al menos una vez»: el mismo evento puede llegar dos veces.

Ventana de terminal
# Manda un webhook.test firmado a tu URL y mira qué respondió tu receptor:
curl -X POST https://motor.ejemplo.com/v1/subscriptions/SUSCRIPCION_ID/test \
-H "Authorization: Bearer TU_API_KEY"
# Y verifica a mano una firma que guardaste (cuerpo.json es el cuerpo crudo):
printf '%s.%s.' "$TIMESTAMP" "$DELIVERY_ID" | cat - cuerpo.json \
| openssl dgst -sha256 -hmac "TU_SECRETO_DE_WEBHOOK" -hex

El webhook.Receptor del SDK hace los pasos de la firma en orden, responde 408 si el timestamp está fuera de tolerancia, 401 si la firma no vale, 400 si el cuerpo no es un evento, 500 si no pudo guardar y 204 después del commit. InboxSQL es un ejemplo sobre Postgres; con otra base, implementa la interfaz webhook.Inbox. La referencia del SDK está en SDK de Go.

Tu respuesta Qué hace el motor
2xx Entregada. Es lo único que cuenta como entregada.
401 o 403 Suspende la suscripción y abre una alerta para el operador: el secreto está mal y reintentar no sirve. Lo pendiente espera.
408, 429, 5xx, timeout o error de red Reintenta (respeta un Retry-After de hasta 1 hora).
Otro 4xx (400, 404, 422…) Fallida enseguida, sin reintentos.
3xx Fallida (redireccion): no se siguen redirecciones.

Los reintentos van a 1 s, 5 s, 30 s, 2 min y 10 min, y después cada hora, con ±20 % de variación. A las 72 horas, una entrega que sigue fallando queda fallida; el evento nunca se borra del ledger y lo puedes pedir de nuevo con un replay (scope deliveries:replay). Cada intento espera tu respuesta hasta 10 segundos (Ajustes → Webhooks).

Para reactivar una suscripción suspendida, corrige el secreto y manda PATCH /v1/subscriptions/{id} con {"estado": "activa"}: lo pendiente sale enseguida. Con {"estado": "pausada"} la detienes sin perder lo pendiente.

POST /v1/subscriptions/{id}/test manda en el momento un evento webhook.test firmado, por el mismo camino que una entrega real, y te devuelve qué respondió tu receptor:

200 OK
{ "ok": true, "status": 204, "duracion_ms": 38 }

Si falla, ok es false y trae status o un error saneado (timeout, dns, tls, conexion_rechazada, destino_no_permitido…). El webhook.test no entra en el ledger: su instance_id va en ceros, su seq en 0 y su data es {"prueba": true}. Ignóralo en tu lógica de negocio, como hace el ejemplo de Go.

Para ver qué pasó con las entregas reales: GET /v1/deliveries (scope events:read), con estado=pendiente,fallida, trae cada entrega con sus intentos, el último status y el último error.

Síntoma Causa Qué hacer
422 body.url al crear El host no está en los destinos permitidos. Pídeselo al operador.
El test da ok: false y status: 401 Tu receptor no reconoce la firma. Revisa que usas el cuerpo crudo, el valor completo (con whsec_) y el key_id correcto.
La suscripción pasó a suspendida Tu receptor respondió 401 o 403. Corrige y reactívala con PATCH.
Entregas fallida con http_400 o http_404 Tu receptor respondió un 4xx. Arréglalo y pide un replay de esos eventos.
Llegan eventos repetidos Un reintento o un replay: es lo esperado. Deduplica por event_id.
No llegan eventos de antes de crearla La suscripción entrega desde que existe. Léelos del ledger o pide un replay.

Lo que trae cada tipo de evento está en el Catálogo de eventos. Para consumir sin exponer una URL, mira Streams SSE y WebSocket.