Ir al contenido

Webhooks

Un webhook es la forma más simple de recibir los eventos: el motor hace un POST firmado a una URL de tu red por cada evento del ledger que cubre tu suscripción. La entrega es durable (la entrega se guarda en la misma transacción que el evento), al menos una vez y, por omisión, en orden de seq por instancia.

Si tu proceso no puede recibir conexiones, usa un stream; si ya tienes un bus de mensajes, un broker. Los tres entregan el mismo sobre.

Ruta Scope Qué hace
POST /v1/subscriptions subscriptions:manage Crea una suscripción. Devuelve el secreto una sola vez.
GET /v1/subscriptions · GET /v1/subscriptions/{id} subscriptions:manage Lista o lee. Nunca devuelve secretos, solo sus key_id.
PATCH /v1/subscriptions/{id} subscriptions:manage Cambia campos, pausa o reactiva.
DELETE /v1/subscriptions/{id} subscriptions:manage La elimina: no crea entregas nuevas ni manda las pendientes. Nunca borra eventos.
POST /v1/subscriptions/{id}/rotate subscriptions:manage Emite un secreto nuevo (una sola vez).
POST /v1/subscriptions/{id}/test subscriptions:manage Manda un webhook.test firmado y devuelve el resultado.
POST /v1/subscriptions/{id}/replays deliveries:replay Reenvía eventos ya aceptados.
GET /v1/deliveries · GET /v1/deliveries/{id} events:read Las entregas de tu app y los intentos de cada una.

Un operador también puede crear y administrar las suscripciones de tu app en el panel (ficha de la app → Webhooks). Si lo hace él, tu key no necesita subscriptions:manage ni deliveries:replay.

Campo Qué es
nombre De 1 a 120 caracteres.
url http:// o https://, de 8 a 2000 caracteres, sin usuario ni contraseña ni fragmento (#). Su host tiene que estar en los destinos permitidos.
instancias Hasta 200 IDs. Vacío: todas las instancias de la app. Una key restringida a instancias tiene que nombrarlas, y solo las suyas.
tipos Hasta 50 tipos de evento. Vacío: todos.
ordenado Por omisión, true: los eventos de una instancia salen en orden de seq. Ver El orden.

La respuesta suma lo que mantiene el motor:

Campo Qué es
estado activa, pausada, suspendida o eliminada.
motivo · suspendida_en Por qué y cuándo se suspendió (rechazo_401, rechazo_403, destino_no_permitido, destino_bloqueado, sin_secreto).
claves Los secretos vigentes, sin su valor: key_id, creado_en y vence_en (con valor solo durante una rotación).
pendientes · fallidas Cuántas entregas esperan y cuántas fallaron.
version Sube con cada cambio. Mándala en version_esperada de un PATCH: si es vieja, 409.

El PATCH acepta nombre, url, instancias, tipos, ordenado, estado (activa o pausada) y version_esperada.

Estado Qué pasa con los eventos nuevos
activa Se entregan.
pausada Se acumulan como entregas pendientes; salen al reactivarla.
suspendida Ídem: se acumulan. La suspende el motor (ver La suspensión).
eliminada No se crean entregas. Lo pendiente no sale.
Ventana de terminal
curl -X POST https://motor.ejemplo.com/v1/subscriptions \
-H "Authorization: Bearer TU_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"nombre": "CRM principal",
"url": "https://crm.ejemplo.com/webhooks/motor",
"tipos": ["message.received", "message.updated", "command.failed"]
}'
# Un webhook.test firmado, para probar el destino:
curl -X POST https://motor.ejemplo.com/v1/subscriptions/SUSCRIPCION_ID/test \
-H "Authorization: Bearer TU_API_KEY"
201 Created
{
"suscripcion": {
"id": "0f3b9d22-0000-4000-8000-0000000000d1",
"cliente_id": "8a1d2c3e-0000-4000-8000-000000000001",
"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": [{ "key_id": "SECRETO_KEY_ID", "creado_en": "2026-10-24T15:00:00Z", "vence_en": null }],
"pendientes": 0,
"fallidas": 0,
"creada_en": "2026-10-24T15:00:00Z",
"actualizada_en": "2026-10-24T15:00:00Z"
},
"secreto": { "key_id": "SECRETO_KEY_ID", "valor": "TU_SECRETO_DE_WEBHOOK" }
}

POST …/test responde { ok, status, duracion_ms, error }: ok es true con un 2xx y error es un código saneado (ver Las entregas). El evento de prueba no entra al ledger.

El motor no entrega a cualquier URL. El host tiene que estar en la lista de destinos permitidos: la global de Ajustes (webhooks.destinos_permitidos) más la propia de tu app. Las dos las maneja el operador; si el host no está, el alta responde 422 en body.url.

  • Cada entrada es un host (cualquier puerto) o un host:puerto (solo ese puerto).
  • Las direcciones de metadatos de la nube se bloquean siempre (169.254.0.0/16, fe80::/10, metadata.google.internal y similares), aunque un nombre permitido resuelva a ellas.
  • localhost y las IP de loopback solo valen si la lista nombra esa IP literal.
  • El motor valida la IP real al conectar, no solo la URL, y no sigue redirects.

La misma lista vale para la medio.url de un envío de multimedia y para los brokers.

Cada intento es un POST a tu URL con el sobre del evento como cuerpo y estos encabezados:

Encabezado Qué es
Content-Type application/json.
User-Agent motor-whatsapp/webhooks.
X-MW-Event-Id El event_id: deduplica por este valor.
X-MW-Delivery-Id El ID de la entrega. Es el mismo en todos los reintentos de esa entrega; un replay crea entregas nuevas, con otro ID.
X-MW-Timestamp Segundos Unix del intento. Cambia en cada reintento.
X-MW-Key-Id El key_id del secreto que firma. Durante una rotación, dos, separados por coma.
X-MW-Signature v1=<hex>. Durante una rotación, dos, separadas por coma, en el mismo orden que X-MW-Key-Id.

Cada intento espera tu respuesta hasta 10 s (webhooks.timeout_segundos, de 1 a 60). El motor lee y descarta a lo sumo 4 KB de tu respuesta: no mandes nada útil ahí.

X-MW-Signature: v1=hex( HMAC-SHA256( secreto, timestamp + "." + delivery_id + "." + cuerpo ) )
  • timestamp es el valor de X-MW-Timestamp, tal cual (texto).
  • delivery_id es el valor de X-MW-Delivery-Id.
  • cuerpo son los bytes crudos del cuerpo, antes de parsear. Si tu framework parsea el JSON y lo vuelve a serializar, la firma ya no coincide.

Para verificar:

  1. Lee el cuerpo crudo.
  2. Rechaza un X-MW-Timestamp a más de 5 minutos de tu reloj (el SDK usa esa tolerancia) con un 408, no con un 401. El motor firma cada intento, también un replay, con la hora de ese intento: un timestamp fuera de tolerancia es un reloj desfasado, no un secreto equivocado. Un 408 se reintenta con una firma nueva; un 401 suspendería la suscripción.
  3. Para cada par (X-MW-Key-Id, X-MW-Signature) en la misma posición, busca el secreto de ese key_id y compara la firma esperada en tiempo constante. Basta con que una valga.
  4. Solo entonces parsea el JSON.

El secreto se ve así: key_id whk_… y valor whsec_…. Se muestra al crear la suscripción y al rotarla, y nunca más.

POST /v1/subscriptions/{id}/rotate emite un secreto nuevo y lo devuelve una sola vez. El anterior sigue firmando durante la ventana de rotación (60 min por omisión; webhooks.ventana_rotacion_minutos, de 5 a 1440) y después vence. Durante la ventana, cada entrega lleva las dos firmas, la nueva primero.

Para rotar sin perder entregas:

  1. Llama a rotate y guarda el secreto nuevo.
  2. Agrega el key_id nuevo a las claves de tu receptor sin quitar el viejo.
  3. Dentro de la ventana, quita el viejo.

Como cada entrega trae las dos firmas, el receptor acepta tanto antes como después del paso 2.

Una entrega se da por entregada solo con un 2xx. Lo demás depende de qué respondas:

Respuesta Qué hace el motor
2xx entregada.
401 o 403 Suspende la suscripción y abre una alerta para el operador. La entrega queda pendiente.
408, 429, 5xx, timeout o error de red Reintenta con el calendario de abajo.
3xx fallida con redireccion: el motor no sigue redirects.
Otro 4xx (400, 404, 410, 422…) fallida enseguida, sin reintentos.

El calendario de reintentos, contado desde cada intento fallido:

Intento fallido Espera antes del siguiente
1.º 1 s
2.º 5 s
3.º 30 s
4.º 2 min
5.º 10 min
6.º y siguientes 1 h

Cada espera varía ±20 % al azar. Si respondes 429 o 503 con Retry-After (segundos o una fecha), el motor espera lo mayor entre su calendario y tu Retry-After, hasta 1 h. Cuando una entrega lleva 72 h desde que se creó y vuelve a fallar, queda fallida (su ultimo_error termina en _agotada) y puedes pedir un replay.

El motor suspende una suscripción (estado: suspendida) y abre una alerta en el panel cuando:

motivo Causa
rechazo_401 · rechazo_403 Tu receptor respondió 401 o 403: casi siempre, un secreto mal configurado.
destino_no_permitido El host salió de los destinos permitidos.
destino_bloqueado La URL resuelve a una dirección bloqueada.
sin_secreto La suscripción no tiene un secreto vigente.

Mientras está suspendida, los eventos nuevos se acumulan como entregas pendientes: no se pierde nada. Arregla la causa y reactívala con PATCH /v1/subscriptions/{id} y {"estado": "activa"} (o desde el panel): la alerta se cierra y lo pendiente sale enseguida.

GET /v1/deliveries lista las entregas de tu app, de la más nueva a la más vieja. Filtra con estado (varios separados por coma), suscripcion_id e instancia_id; pagina con pagina y por_pagina (hasta 200). GET /v1/deliveries/{id} suma intentos_detalle: cada intento con su número, su hora, su duración, su status y los key_id con que se firmó.

estado Qué significa
pendiente Espera su intento (proximo_intento_en), su turno en el orden o que la suscripción vuelva a estar activa.
entregada Tu receptor respondió 2xx. Se borra a los 30 días (retencion.dias_entregados); el evento queda en el ledger.
fallida No se va a reintentar sola. Nunca se borra: es la cola de fallidas (DLQ). Se reenvía con un replay.

ultimo_error es un código saneado, nunca la URL ni un cuerpo:

Código Qué pasó
http_<status> Tu receptor respondió ese status (por ejemplo, http_500).
timeout No respondió a tiempo.
conexion_rechazada · conexion_cortada No aceptó la conexión, o la cortó.
dns · tls · red No resolvió el nombre, falló el TLS u otro error de red.
redireccion Respondió un 3xx.
destino_no_permitido · destino_bloqueado · sin_secreto · url Un problema de configuración (suspende la suscripción, salvo url).
…_agotada El último error, después de 72 h de reintentos.
GET /v1/deliveries/{id} · 200 OK
{
"delivery_id": "3c9a7e55-0000-4000-8000-0000000000e1",
"suscripcion_id": "0f3b9d22-0000-4000-8000-0000000000d1",
"suscripcion": "CRM principal",
"instance_id": "8a1d2c3e-0000-4000-8000-0000000000a1",
"instancia": "Sucursal centro",
"event_id": "e1f0c2d4-0000-4000-8000-000000001187",
"seq": 1187,
"type": "message.received",
"estado": "pendiente",
"intentos": 2,
"proximo_intento_en": "2026-10-24T15:08:49Z",
"ultimo_status": 503,
"ultimo_error": "http_503",
"creada_en": "2026-10-24T15:08:13Z",
"intentos_detalle": [
{ "numero": 1, "iniciado_en": "2026-10-24T15:08:13Z", "duracion_ms": 41, "status": 503, "error": "http_503", "key_ids": "SECRETO_KEY_ID" },
{ "numero": 2, "iniciado_en": "2026-10-24T15:08:14Z", "duracion_ms": 38, "status": 503, "error": "http_503", "key_ids": "SECRETO_KEY_ID" }
]
}

POST /v1/subscriptions/{id}/replays (scope deliveries:replay) crea entregas nuevas de eventos que ya están en el ledger. Sirve para recuperar las fallidas o para volver a cargar un rango después de un problema en tu lado.

Campo Qué es
Idempotency-Key (encabezado) Obligatoria, de 1 a 200 caracteres. La misma clave con otro pedido es 409.
instancia_id · desde_seq · hasta_seq Un rango de seq de una instancia, de hasta 10 000 eventos.
event_ids O una lista de hasta 500 event_id. No las dos cosas.
motivo Obligatorio, de 1 a 300 caracteres: queda en la auditoría.
  • Reenvía los mismos hechos: el mismo event_id y el mismo occurred_at, con un X-MW-Delivery-Id nuevo. Tu inbox los descarta si ya los tenía.
  • Solo reenvía lo que cubre la suscripción (sus instancias y sus tipos).
  • Un evento cuyo contenido venció sale redactado, como en el ledger.
  • Nunca vuelve a mandar nada a WhatsApp.
Ventana de terminal
curl -X POST https://motor.ejemplo.com/v1/subscriptions/SUSCRIPCION_ID/replays \
-H "Authorization: Bearer TU_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: recuperar-caida-2026-10-24" \
-d '{
"instancia_id": "INSTANCIA_ID",
"desde_seq": 1180,
"hasta_seq": 1220,
"motivo": "El receptor estuvo caído más de 72 h"
}'
202 Accepted
{
"replay_id": "7d1e4b90-0000-4000-8000-0000000000f1",
"suscripcion_id": "0f3b9d22-0000-4000-8000-0000000000d1",
"entregas": 41,
"creado_en": "2026-10-24T16:00:00Z"
}

Con ordenado: true (por omisión), el motor no entrega un evento de una instancia mientras quede pendiente uno anterior de esa misma instancia en esa suscripción. Cada instancia es un carril propio: una instancia lenta no frena a las demás.

  • Un evento que queda fallida ya no está pendiente: el carril sigue con el siguiente. El orden se mantiene, pero ese evento falta hasta que pidas un replay.
  • Un replay tiene su propio carril, también en orden de seq, independiente de las entregas normales: tu receptor puede recibir un evento viejo del replay intercalado con los nuevos.
  • Entre instancias distintas no hay orden.

Con ordenado: false, las entregas salen en paralelo (hasta 8 a la vez por proceso) y pueden llegar en cualquier orden. Procesa por seq de tu lado si lo necesitas.

La entrega es al menos una vez. El mismo event_id puede llegarte dos veces:

  • el motor recibió tu 2xx pero se cayó antes de anotarlo (llega con el mismo X-MW-Delivery-Id);
  • un replay (llega con otro X-MW-Delivery-Id);
  • dos suscripciones de tu app que cubren el mismo evento.

La regla: guarda el event_id con una restricción UNIQUE en la misma transacción que el efecto del evento, y responde 2xx solo después del commit. Así una repetición no produce un segundo efecto. Lo explica «Al menos una vez».

Ventana de terminal
# Prueba tu receptor a mano con una entrega firmada como la del motor.
SECRETO='TU_SECRETO_DE_WEBHOOK'
TS=$(date +%s)
DELIVERY='3c9a7e55-0000-4000-8000-0000000000e1'
CUERPO='{"schema_version":1,"event_id":"e1f0c2d4-0000-4000-8000-000000001187","instance_id":"8a1d2c3e-0000-4000-8000-0000000000a1","seq":1187,"type":"webhook.test","occurred_at":"2026-10-24T15:08:12Z","received_at":"2026-10-24T15:08:13Z","generation":1,"origin":"operator","data":{"prueba":true}}'
FIRMA=$(printf '%s.%s.%s' "$TS" "$DELIVERY" "$CUERPO" | openssl dgst -sha256 -hmac "$SECRETO" -hex | sed 's/^.* //')
curl -i -X POST https://crm.ejemplo.com/webhooks/motor \
-H "Content-Type: application/json" \
-H "X-MW-Key-Id: SECRETO_KEY_ID" \
-H "X-MW-Timestamp: $TS" \
-H "X-MW-Delivery-Id: $DELIVERY" \
-H "X-MW-Event-Id: e1f0c2d4-0000-4000-8000-000000001187" \
-H "X-MW-Signature: v1=$FIRMA" \
--data-raw "$CUERPO"