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.
1. Pide el destino permitido
Sección titulada «1. Pide el destino permitido»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
superadminla 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://ohttps://, sin usuario ni contraseña y sin#fragmento; - los servicios de metadatos de la nube (
169.254.0.0/16,metadata.google.internaly parecidos) están bloqueados, aunque estén en la lista; localhosty127.0.0.1solo 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.
2. Crea la suscripción
Sección titulada «2. Crea la suscripción»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.
{ "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/subscriptionsno aceptaIdempotency-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. |
3. La firma
Sección titulada «3. La firma»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:
- Lee el cuerpo crudo, sin parsearlo ni volver a serializarlo: un JSON reformateado no coincide.
- 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.
- Para cada
key_idque conoces, calcula el HMAC y compáralo en tiempo constante con la firma de la misma posición. Basta con que una coincida. - 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.
4. Monta el receptor
Sección titulada «4. Monta el receptor»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.
# 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" -hexpackage main
import ( "context" "database/sql" "log" "net/http" "os"
_ "github.com/jackc/pgx/v5/stdlib" "github.com/projectx27/motor-whatsapp/sdk/go/webhook")
func main() { ctx := context.Background() db, err := sql.Open("pgx", os.Getenv("CRM_DATABASE_URL")) if err != nil { log.Fatal(err) } // El inbox: una tabla con PRIMARY KEY (cliente, instancia, event_id). inbox := webhook.InboxSQL{DB: db, Cliente: "motor-principal"} if err := inbox.Crear(ctx); err != nil { log.Fatal(err) } receptor := webhook.Receptor{ // key_id → secreto. Durante una rotación, los dos. Verificador: webhook.Verificador{Claves: map[string]string{ os.Getenv("MW_WEBHOOK_KEY_ID"): os.Getenv("MW_WEBHOOK_SECRETO"), }}, Inbox: inbox, // Corre dentro de la transacción del inbox: si falla, no queda nada // guardado, el receptor responde 500 y el motor reintenta. Efecto: func(ctx context.Context, tx *sql.Tx, ev webhook.Evento) error { switch ev.Type { case "webhook.test": return nil case "message.received": _, err := tx.ExecContext(ctx, `INSERT INTO mensajes_entrantes (event_id, instancia, datos) VALUES ($1, $2, $3)`, ev.EventID, ev.InstanceID, ev.Data) return err } return nil }, } http.Handle("/webhooks/motor", receptor) log.Fatal(http.ListenAndServe(":3000", nil))}import { createServer } from 'node:http';import { createHmac, timingSafeEqual } from 'node:crypto';
// key_id → secreto, guardados sellados en tu app. Durante una rotación, los dos.const claves = { [process.env.MW_WEBHOOK_KEY_ID]: process.env.MW_WEBHOOK_SECRETO };
// 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;}
function firmaValida(h, cuerpo) { const ts = h['x-mw-timestamp']; const entrega = h['x-mw-delivery-id']; if (!ts || !entrega) return false; const ids = String(h['x-mw-key-id'] ?? '').split(','); const firmas = String(h['x-mw-signature'] ?? '').split(','); if (ids.length !== firmas.length) return false; 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}.${entrega}.`).update(cuerpo).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' || req.url !== '/webhooks/motor') return res.writeHead(404).end(); const partes = []; for await (const parte of req) partes.push(parte); const cuerpo = Buffer.concat(partes); // crudo: la firma va sobre estos bytes if (!timestampVigente(req.headers)) return res.writeHead(408).end(); if (!firmaValida(req.headers, cuerpo)) return res.writeHead(401).end(); const evento = JSON.parse(cuerpo); try { // En una transacción: INSERT … ON CONFLICT (event_id) DO NOTHING y, si era nuevo, tu efecto. await guardarUnaVez(evento); res.writeHead(204).end(); // solo después del commit } catch { res.writeHead(500).end(); // el motor reintenta }}).listen(3000);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.
Qué hace el motor con tu respuesta
Sección titulada «Qué hace el motor con tu respuesta»| 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.
Probar el destino
Sección titulada «Probar el destino»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:
{ "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.
Errores comunes
Sección titulada «Errores comunes»| 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. |
Siguiente paso
Sección titulada «Siguiente paso»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.