Avisos salientes (webhooks)
Con los avisos salientes tus sistemas se enteran al instante de lo que pasa en Zoan Automation, sin consultar la API cada pocos minutos:
- Cerrar el ciclo con el ERP: cuando termina la ejecución que registró una factura, el ERP recibe el aviso y actualiza su estado.
- Encadenar procesos: al terminar un caso de un flujo, tu backend arranca el paso siguiente en otro sistema.
- Llevar las tareas a donde trabaja la gente: cada tarea humana nueva llega a tu herramienta interna, a un canal de chat o a un tablero.
- Avisar a quien valida documentos: cuando un documento de IDP necesita revisión, tu sistema lo sabe en el momento.
Cada aviso es un POST JSON firmado a una URL tuya. Si tu sistema no responde, Zoan Cloud reintenta con espera creciente, y cada entrega queda en un registro que puedes consultar y reenviar.
Eventos
Sección titulada «Eventos»| Evento | Cuándo se envía | Qué trae data |
|---|---|---|
execution.finished | Una ejecución de robot terminó: completada, fallida, detenida o cancelada. Las pruebas del Designer no avisan. | execution: el mismo objeto que GET /executions/{id} sin entradas ni salidas (id, proceso, estado, máquina, códigos de error, fechas, caso del flujo si lo hay). |
case.finished | Un caso de Zoan Flow terminó: completado o cancelado. | case: id, flujo, versión, estado, motivo de cierre y fechas. |
human_task.created | Se creó una tarea humana (por un robot, un flujo, un asistente o la API). | task: id, título, prioridad, origen, a quién va dirigida, vencimiento y caso o ítem de cola. |
human_task.decided | Alguien decidió una tarea humana (no avisa vencimientos ni cancelaciones). | task: id, título, decisión, quién y cuándo. |
document.review_required | Un documento de IDP quedó extraído y espera a que una persona lo valide. | document: id, referencia, archivo, tipo, proyecto y motivo de revisión (fields, policy o qa-sample). |
Los avisos llevan identificadores y estados, no datos de negocio: ni las entradas o salidas de las ejecuciones, ni las variables de los casos, ni los campos de los formularios. Con el id, tu sistema pide a la API lo que necesite, con los permisos de su clave.
Quién puede suscribirse a cada evento
Sección titulada «Quién puede suscribirse a cada evento»Un aviso nunca cuenta más de lo que su creador puede ver. Por eso, además del permiso webhook:manage, quien crea la suscripción necesita:
| Evento | Permiso adicional |
|---|---|
execution.finished | execution:list en todo el ambiente (la raíz y todas las carpetas). |
document.review_required | document:list en todo el ambiente. |
case.finished | orchestration:list. |
human_task.created, human_task.decided | human_task:list y human_task:view_all (las tareas son de quien van dirigidas: avisar de todas exige verlas todas). |
Si falta alguno, la respuesta es 403 WEBHOOK_EVENT_FORBIDDEN con lo que hace falta. Ver las suscripciones y su registro pide webhook:list. Owner y Admin tienen los dos permisos; para una integración, agrégalos a un rol propio.
Crear un aviso
Sección titulada «Crear un aviso»Desde el portal
Sección titulada «Desde el portal»- Entra a Centro › Recursos › Avisos salientes del ambiente.
- Pulsa Crear aviso, escribe un nombre, la URL que recibe los avisos y marca los eventos.
- Copia el secreto de firma que aparece al crearlo: se muestra una sola vez. Guárdalo en el gestor de secretos del sistema que recibe.
- Con Enviar prueba llega un aviso
webhook.pingfirmado igual que los demás: así compruebas la firma antes de esperar un evento real.
Por la API
Sección titulada «Por la API»curl -s -X POST "$ZOAN_API/environments/$ENV/webhooks" \ -H "Authorization: Bearer $ZOAN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "ERP — estado de facturas", "url": "https://erp.example.com/zoan/avisos", "events": ["execution.finished", "case.finished"] }'{ "webhook": { "id": "b7e2c1d4-58a3-4f0b-9c61-2e7d4a9f0b13", "name": "ERP — estado de facturas", "url": "https://erp.example.com/zoan/avisos", "events": ["execution.finished", "case.finished"], "status": "active", "disabled_reason": null, "secret_last_four": "Q9xZ", "previous_secret_valid_until": null, "consecutive_failures": 0, "created_at": "2026-10-16T15:20:11Z", "updated_at": "2026-10-16T15:20:11Z" }, "secret": "whsec_EJEMPLOxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxQ9xZ"}Las demás rutas (listar, cambiar, activar o desactivar, rotar el secreto, probar, borrar y consultar las entregas) están en la referencia de la API.
Requisitos de la URL
Sección titulada «Requisitos de la URL»Para que nadie use los avisos para alcanzar redes internas, la URL debe:
- empezar por
https://(los avisos solo viajan cifrados); - apuntar a un servidor público de internet: no se aceptan
localhost, nombres internos (.local,.internal,.svc…), ni direcciones privadas, de loopback, link-local o reservadas; - no llevar usuario ni contraseña.
La revisión se repite en cada envío: si el nombre de tu servidor pasa a resolver a una dirección privada, el aviso no sale. Zoan Cloud no sigue redirecciones (un 3xx cuenta como fallo): usa la URL final.
Qué recibe tu sistema
Sección titulada «Qué recibe tu sistema»POST /zoan/avisos HTTP/1.1Host: erp.example.comContent-Type: application/jsonUser-Agent: ZoanCloud-Webhooks/1.0Zoan-Webhook-Id: 3f1c8e52-9a7d-4b06-8e21-5c0d9b7a4f68Zoan-Webhook-Event: execution.finishedZoan-Webhook-Attempt: 1Zoan-Webhook-Timestamp: 1760628011Zoan-Webhook-Signature: v1=5d0b0c8e9f4a…
{ "id": "3f1c8e52-9a7d-4b06-8e21-5c0d9b7a4f68", "type": "execution.finished", "created_at": "2026-10-16T15:20:11.482Z", "environment_id": "7c1e4a52-3b9d-4f0e-9a61-2d8f5b0c9e11", "data": { "execution": { "id": "e4a90b17-2c3d-4f58-b6e1-0d7c9a2f5b34", "process_id": "7d1f3c2a-0000-4000-8000-000000000001", "process_name": "Registrar facturas", "status": "completed", "source": "manual", "agent_id": null, "case_id": null, "exit_code": 0, "error_message": null, "failure_code": null, "created_at": "2026-10-16T15:18:02.110Z", "started_at": "2026-10-16T15:18:04.031Z", "finished_at": "2026-10-16T15:20:10.977Z" } }}| Cabecera | Para qué |
|---|---|
Zoan-Webhook-Id | Id del evento. Es el mismo en cada reintento: guárdalo para no procesar dos veces el mismo aviso. |
Zoan-Webhook-Event | Tipo de evento (igual a type en el cuerpo). |
Zoan-Webhook-Attempt | Número de intento (1, 2, …). |
Zoan-Webhook-Timestamp | Momento del envío, en segundos Unix. Va dentro de la firma. |
Zoan-Webhook-Signature | Firma: v1=<hex>. Durante una rotación trae dos, separadas por coma. |
Responde con un código 2xx en menos de 10 segundos. Si tu sistema tarda más en procesar, responde primero y procesa después (por ejemplo, encolando el aviso). Cualquier otra respuesta, o ninguna, se reintenta.
Verificar la firma
Sección titulada «Verificar la firma»La firma es un HMAC-SHA256 con el secreto de la suscripción sobre el texto "<timestamp>.<cuerpo>", en hexadecimal. Para verificar:
- Toma el cuerpo crudo tal como llegó (los bytes, antes de convertirlo a JSON: si lo vuelves a serializar, la firma no coincide).
- Calcula
HMAC-SHA256(secreto, timestamp + "." + cuerpo)en hexadecimal. - Compáralo, en tiempo constante, con cada valor
v1=deZoan-Webhook-Signature: basta con que coincida uno. - Rechaza el aviso si
Zoan-Webhook-Timestampse aleja más de 5 minutos de tu reloj (evita que alguien repita un aviso viejo).
Node.js (Express)
Sección titulada «Node.js (Express)»import crypto from 'node:crypto';import express from 'express';
const SECRET = process.env.ZOAN_WEBHOOK_SECRET; // whsec_…const app = express();
// El cuerpo crudo: la firma se calcula sobre los bytes recibidos.app.post('/zoan/avisos', express.raw({ type: 'application/json' }), (req, res) => { const timestamp = req.get('Zoan-Webhook-Timestamp') ?? ''; const header = req.get('Zoan-Webhook-Signature') ?? ''; const body = req.body.toString('utf8');
const age = Math.abs(Date.now() / 1000 - Number(timestamp)); if (!Number.isFinite(age) || age > 300) return res.status(400).send('Aviso vencido');
const expected = crypto.createHmac('sha256', SECRET).update(`${timestamp}.${body}`).digest(); const valid = header.split(',').map((p) => p.trim()).filter((p) => p.startsWith('v1=')).some((p) => { const got = Buffer.from(p.slice(3), 'hex'); return got.length === expected.length && crypto.timingSafeEqual(got, expected); }); if (!valid) return res.status(401).send('Firma inválida');
const event = JSON.parse(body); // Idempotencia: si ya procesaste event.id, responde 200 sin hacer nada más. res.sendStatus(204); procesar(event); // después de responder});Python (Flask)
Sección titulada «Python (Flask)»import hashlib, hmac, os, timefrom flask import Flask, request, abort
SECRET = os.environ["ZOAN_WEBHOOK_SECRET"].encode() # whsec_…app = Flask(__name__)
@app.post("/zoan/avisos")def avisos(): timestamp = request.headers.get("Zoan-Webhook-Timestamp", "") header = request.headers.get("Zoan-Webhook-Signature", "") body = request.get_data() # bytes crudos
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300: abort(400) expected = hmac.new(SECRET, timestamp.encode() + b"." + body, hashlib.sha256).hexdigest() firmas = [p.strip()[3:] for p in header.split(",") if p.strip().startswith("v1=")] if not any(hmac.compare_digest(f, expected) for f in firmas): abort(401)
evento = request.get_json() # Idempotencia: guarda evento["id"] y no lo proceses dos veces. return "", 204C# (ASP.NET Core)
Sección titulada «C# (ASP.NET Core)»app.MapPost("/zoan/avisos", async (HttpRequest req) =>{ var secret = Encoding.UTF8.GetBytes(Environment.GetEnvironmentVariable("ZOAN_WEBHOOK_SECRET")!); using var reader = new StreamReader(req.Body, Encoding.UTF8); var body = await reader.ReadToEndAsync(); var timestamp = req.Headers["Zoan-Webhook-Timestamp"].ToString(); var header = req.Headers["Zoan-Webhook-Signature"].ToString();
if (!long.TryParse(timestamp, out var ts) || Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > 300) return Results.BadRequest();
using var hmac = new HMACSHA256(secret); var expected = hmac.ComputeHash(Encoding.UTF8.GetBytes($"{timestamp}.{body}")); var valid = header.Split(',').Select(p => p.Trim()).Where(p => p.StartsWith("v1=")) .Any(p => CryptographicOperations.FixedTimeEquals(Convert.FromHexString(p[3..]), expected)); if (!valid) return Results.Unauthorized();
// Idempotencia: usa el campo "id" del evento. return Results.NoContent();});Reintentos, desactivación y registro
Sección titulada «Reintentos, desactivación y registro»- Reintentos: si la entrega falla (sin respuesta, error de red, código distinto de 2xx o una redirección), se reintenta hasta 8 veces con espera creciente: unos 30 segundos, 2 minutos, 8 minutos, 32 minutos, 2 horas y después cada 6 horas. Todos los intentos llevan el mismo
Zoan-Webhook-Id. - Desactivación automática: tras 10 entregas fallidas seguidas (cada una con sus intentos agotados), el aviso se desactiva solo y los administradores reciben un correo. Una entrega correcta reinicia la cuenta. Cuando tu sistema vuelva a responder, actívalo de nuevo y reenvía desde el registro las entregas fallidas.
- Registro de entregas: cada aviso guarda sus entregas de los últimos 30 días con su estado (pendiente, entregando, entregada, fallida u omitida), intentos, código HTTP, error y duración. Una entrega omitida es una que encontró el aviso desactivado.
- Orden: los avisos no garantizan orden (un reintento puede llegar después de un aviso más nuevo). Usa
created_atdel cuerpo y el estado del recurso para decidir.
Rotar el secreto
Sección titulada «Rotar el secreto»Rotar genera un secreto nuevo, que se muestra una sola vez. Durante 24 horas cada aviso lleva dos firmas: la del secreto nuevo y la del anterior. Así cambias el secreto en tu sistema sin perder avisos: actualízalo dentro de ese plazo y, al terminar, solo queda la firma nueva.
Rota cuando el secreto pudo quedar expuesto y, como buena práctica, de forma periódica.
Buenas prácticas
Sección titulada «Buenas prácticas»- Verifica siempre la firma y la antigüedad del timestamp antes de confiar en el aviso.
- Responde rápido (2xx) y procesa después; un receptor lento acumula reintentos.
- Procesa una sola vez cada
Zoan-Webhook-Id: los reintentos y una reactivación pueden repetir un aviso. - Consulta la API para el detalle: el aviso dice qué cambió; los datos los lees con una clave de API con lo mínimo que la integración necesita.
- Un aviso por sistema receptor: si uno falla o hay que desactivarlo, los demás siguen.
Siguientes pasos
Sección titulada «Siguientes pasos»- API de Zoan Cloud — claves, permisos y todas las rutas, incluidas las de los avisos.
- Usuarios y roles — crea un rol propio con
webhook:managey los permisos de los eventos. - Disparadores por webhook — el camino contrario: que otro sistema inicie un proceso o un flujo en Zoan Cloud.