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.
Las rutas
Sección titulada «Las rutas»| 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.
La suscripción
Sección titulada «La suscripción»| 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. |
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"sus, sec, err := c.CrearSuscripcion(ctx, motor.NuevaSuscripcion{ Nombre: "CRM principal", URL: "https://crm.ejemplo.com/webhooks/motor", Tipos: []string{"message.received", "message.updated", "command.failed"},})if err != nil { return err}// sec.KeyID y sec.Valor: guárdalos sellados. No se vuelven a mostrar.guardarSecreto(sus.ID, sec.KeyID, sec.Valor)
// El SDK no trae /test: es una petición HTTP con el mismo token.req, _ := http.NewRequestWithContext(ctx, http.MethodPost, c.Base+"/v1/subscriptions/"+sus.ID+"/test", nil)req.Header.Set("Authorization", "Bearer "+c.Token)res, err := c.HTTP.Do(req)const res = await fetch('https://motor.ejemplo.com/v1/subscriptions', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ nombre: 'CRM principal', url: 'https://crm.ejemplo.com/webhooks/motor', tipos: ['message.received', 'message.updated', 'command.failed'], }),});const { suscripcion, secreto } = await res.json(); // 201await guardarSecreto(suscripcion.id, secreto.key_id, secreto.valor); // sellado{ "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.
Los destinos permitidos
Sección titulada «Los destinos permitidos»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.internaly similares), aunque un nombre permitido resuelva a ellas. localhosty 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 entrega
Sección titulada «Cada entrega»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í.
La firma
Sección titulada «La firma»X-MW-Signature: v1=hex( HMAC-SHA256( secreto, timestamp + "." + delivery_id + "." + cuerpo ) )timestampes el valor deX-MW-Timestamp, tal cual (texto).delivery_ides el valor deX-MW-Delivery-Id.cuerposon 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:
- Lee el cuerpo crudo.
- Rechaza un
X-MW-Timestampa 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. - Para cada par (
X-MW-Key-Id,X-MW-Signature) en la misma posición, busca el secreto de esekey_idy compara la firma esperada en tiempo constante. Basta con que una valga. - 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.
La rotación
Sección titulada «La rotación»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:
- Llama a
rotatey guarda el secreto nuevo. - Agrega el
key_idnuevo a las claves de tu receptor sin quitar el viejo. - 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.
Las respuestas y los reintentos
Sección titulada «Las respuestas y los reintentos»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.
La suspensión
Sección titulada «La suspensión»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.
Las entregas
Sección titulada «Las entregas»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. |
{ "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" } ]}El replay
Sección titulada «El replay»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_idy el mismooccurred_at, con unX-MW-Delivery-Idnuevo. 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.
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" }'r, err := c.Replay(ctx, suscripcionID, "recuperar-caida-2026-10-24", motor.PedidoReplay{ InstanciaID: instanciaID, DesdeSeq: 1180, HastaSeq: 1220, Motivo: "El receptor estuvo caído más de 72 h",})fmt.Println(r.ID, r.Entregas) // cuántas entregas nuevas creóconst res = await fetch(`https://motor.ejemplo.com/v1/subscriptions/${suscripcionId}/replays`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'Idempotency-Key': 'recuperar-caida-2026-10-24', }, body: JSON.stringify({ instancia_id: instanciaId, desde_seq: 1180, hasta_seq: 1220, motivo: 'El receptor estuvo caído más de 72 h', }),});const replay = await res.json(); // 202: { replay_id, entregas, … }{ "replay_id": "7d1e4b90-0000-4000-8000-0000000000f1", "suscripcion_id": "0f3b9d22-0000-4000-8000-0000000000d1", "entregas": 41, "creado_en": "2026-10-24T16:00:00Z"}El orden
Sección titulada «El orden»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
fallidaya 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 deduplicación
Sección titulada «La deduplicación»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».
El receptor
Sección titulada «El receptor»# 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"import "github.com/projectx27/motor-whatsapp/sdk/go/webhook"
// El inbox: una tabla con clave (cliente, instancia, event_id).inbox := webhook.InboxSQL{DB: db, Cliente: "motor-principal"}if err := inbox.Crear(ctx); err != nil { return err}http.Handle("/webhooks/motor", webhook.Receptor{ // key_id → secreto. Durante una rotación, las dos claves. Verificador: webhook.Verificador{Claves: claves}, Inbox: inbox, Efecto: func(ctx context.Context, tx *sql.Tx, ev webhook.Evento) error { // Tu lógica, en la MISMA transacción que el inbox: si falla, // nada queda guardado, el receptor responde 500 y el motor reintenta. return aplicar(ctx, tx, ev) },})// Responde 204 tras el commit, 408 con un timestamp fuera de tolerancia, 401 con una firma// inválida y 500 si no pudo guardar.import { createServer } from 'node:http';import { createHmac, timingSafeEqual } from 'node:crypto';
// El motor firma cada intento con la hora de ese intento: un timestamp fuera de// los 5 minutos es un reloj desfasado. Responde 408 (se reintenta), no 401// (suspende la suscripción).function timestampVigente(h) { const ts = Number(h['x-mw-timestamp']); return Number.isFinite(ts) && Math.abs(Date.now() / 1000 - ts) <= 300;}
// claves: key_id → secreto, leídos de tu almacén de secretos.function firmaValida(h, cuerpo, claves) { const ts = h['x-mw-timestamp']; if (!ts) return false; const ids = String(h['x-mw-key-id'] ?? '').split(','); const firmas = String(h['x-mw-signature'] ?? '').split(','); return ids.some((id, i) => { const secreto = claves[id.trim()]; const firma = firmas[i]?.trim() ?? ''; if (!secreto || !firma.startsWith('v1=')) return false; const esperada = createHmac('sha256', secreto) .update(`${ts}.${h['x-mw-delivery-id']}.`) .update(cuerpo) // los bytes crudos .digest(); const recibida = Buffer.from(firma.slice(3), 'hex'); return recibida.length === esperada.length && timingSafeEqual(recibida, esperada); });}
createServer(async (req, res) => { if (req.method !== 'POST') return res.writeHead(405).end(); const partes = []; for await (const parte of req) partes.push(parte); const cuerpo = Buffer.concat(partes); if (!timestampVigente(req.headers)) return res.writeHead(408).end(); if (!firmaValida(req.headers, cuerpo, claves)) return res.writeHead(401).end(); const evento = JSON.parse(cuerpo); try { // INSERT del event_id con UNIQUE (ON CONFLICT DO NOTHING) y tu efecto, // en una sola transacción. await guardarUnaVez(evento); res.writeHead(204).end(); } catch { res.writeHead(500).end(); // pasajero: el motor reintenta }}).listen(8090);