Ir al contenido

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.

EventoCuándo se envíaQué trae data
execution.finishedUna 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.finishedUn caso de Zoan Flow terminó: completado o cancelado.case: id, flujo, versión, estado, motivo de cierre y fechas.
human_task.createdSe 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.decidedAlguien decidió una tarea humana (no avisa vencimientos ni cancelaciones).task: id, título, decisión, quién y cuándo.
document.review_requiredUn 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.

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:

EventoPermiso adicional
execution.finishedexecution:list en todo el ambiente (la raíz y todas las carpetas).
document.review_requireddocument:list en todo el ambiente.
case.finishedorchestration:list.
human_task.created, human_task.decidedhuman_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.

  1. Entra a Centro › Recursos › Avisos salientes del ambiente.
  2. Pulsa Crear aviso, escribe un nombre, la URL que recibe los avisos y marca los eventos.
  3. 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.
  4. Con Enviar prueba llega un aviso webhook.ping firmado igual que los demás: así compruebas la firma antes de esperar un evento real.
Ventana de terminal
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.

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.

POST /zoan/avisos HTTP/1.1
Host: erp.example.com
Content-Type: application/json
User-Agent: ZoanCloud-Webhooks/1.0
Zoan-Webhook-Id: 3f1c8e52-9a7d-4b06-8e21-5c0d9b7a4f68
Zoan-Webhook-Event: execution.finished
Zoan-Webhook-Attempt: 1
Zoan-Webhook-Timestamp: 1760628011
Zoan-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"
}
}
}
CabeceraPara qué
Zoan-Webhook-IdId del evento. Es el mismo en cada reintento: guárdalo para no procesar dos veces el mismo aviso.
Zoan-Webhook-EventTipo de evento (igual a type en el cuerpo).
Zoan-Webhook-AttemptNúmero de intento (1, 2, …).
Zoan-Webhook-TimestampMomento del envío, en segundos Unix. Va dentro de la firma.
Zoan-Webhook-SignatureFirma: 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.

La firma es un HMAC-SHA256 con el secreto de la suscripción sobre el texto "<timestamp>.<cuerpo>", en hexadecimal. Para verificar:

  1. Toma el cuerpo crudo tal como llegó (los bytes, antes de convertirlo a JSON: si lo vuelves a serializar, la firma no coincide).
  2. Calcula HMAC-SHA256(secreto, timestamp + "." + cuerpo) en hexadecimal.
  3. Compáralo, en tiempo constante, con cada valor v1= de Zoan-Webhook-Signature: basta con que coincida uno.
  4. Rechaza el aviso si Zoan-Webhook-Timestamp se aleja más de 5 minutos de tu reloj (evita que alguien repita un aviso viejo).
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
});
import hashlib, hmac, os, time
from 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 "", 204
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: 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_at del cuerpo y el estado del recurso para decidir.

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.

  • 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.
  • API de Zoan Cloud — claves, permisos y todas las rutas, incluidas las de los avisos.
  • Usuarios y roles — crea un rol propio con webhook:manage y los permisos de los eventos.
  • Disparadores por webhook — el camino contrario: que otro sistema inicie un proceso o un flujo en Zoan Cloud.