Webhooks
Los webhooks del espacio de trabajo envían un POST JSON firmado a la URL que elijas cuando algo cambia en un espacio: se
crea una tarea, se añade un comentario, se registra tiempo. Úsalos para actualizar otra herramienta sin tener que consultar
la API REST una y otra vez.
- Quién: los admins del espacio, en Ajustes → Espacio de trabajo → Webhooks, o por la API
(
/api/v1/workspaces/{slug}/webhooks, clave con acceso completo). Como máximo 20 por espacio. - Interruptores de la instancia (Admin → General → API): Permitir webhooks (activado por defecto; si se desactiva,
no se registra ni se envía nada, la sección de ajustes queda bloqueada y los endpoints responden 403
webhooks_disabled) y Permitir webhooks a direcciones de red privadas (desactivado por defecto; consulta Direcciones de destino permitidas).
Configurar un webhook
- Ajustes → Espacio de trabajo → Webhooks → Añadir webhook: la URL de destino, una descripción opcional y los eventos (lista de casillas agrupada en tareas, comentarios, tableros, miembros y registros de tiempo).
- El secreto de firma (
whsec_…) se muestra una sola vez: guárdalo en el servicio receptor. sol2flow lo guarda cifrado. Renovar secreto (en Editar) lo sustituye al instante; el anterior deja de funcionar a partir de la siguiente entrega. - Enviar prueba pone en cola un evento
ping(también con el webhook en pausa). Entregas muestra el registro: evento, estado (Entregado, Reintento programado, Pendiente, Fallido), estado HTTP, duración, intentos, próximo intento, los primeros 2 KB de la respuesta y el contenido, con Reenviar (el mismo contenido como una entrega nueva). El interruptor de cada fila lo pausa o lo reanuda.
Eventos
| Evento | Cuándo | data |
|---|---|---|
task.created | se crea una tarea (en la app o por la API) | Task |
task.updated | título, descripción, fechas, etiquetas, asignados, miembros, elementos de checklist marcados / desmarcados | Task (+ changes.fields) |
task.moved | a otra lista (changes.list: {from, to}: nombres de las listas) o a otro tablero (changes.board, changes.key.from) | Task |
task.archived / task.restored | archivada / restaurada; task.restored también cuando se deshace una eliminación | Task |
task.deleted | eliminada (movida a la papelera, deleted: true) | Task |
task_link.added / task_link.removed | se añade / quita un vínculo entre dos tareas (Tareas vinculadas) | { id, type, source, target, created_at, created_by }: type relates, blocks o duplicates, de source a target (cada una { id, key, title, board_id }) |
comment.created / comment.updated / comment.deleted | comentarios y respuestas; un comentario restaurado es comment.updated | Comment (+ task) |
board.created / board.updated / board.archived / board.restored | tableros; updated incluye nombre, acceso, enlace público, miembros y listas (changes.fields) | Board |
member.added / member.removed | alguien se une al espacio (añadido en la página Miembros o por la API, acepta una invitación, añadido desde el diálogo Compartir) o sale / es quitado | Member |
time_entry.created / time_entry.updated / time_entry.deleted | se registra tiempo o se detiene un cronómetro; se modifica; se elimina (los cronómetros en marcha no se notifican) | TimeEntry (+ task) |
ping | solo "Enviar prueba" / POST …/ping | { webhook: { id, url, description, events } } |
No se notifican (todavía): los cambios de membresía hechos por la sincronización de grupos OIDC, las membresías automáticas de admins de la instancia y Admin → Usuarios; los ajustes del espacio; los adjuntos; las notificaciones.
Contenido
{
"id": "0b5a3a7e-7a0c-4d8e-9f0e-2c3f9b7e1a42",
"event": "task.moved",
"created_at": "2026-09-25T09:30:00.000Z",
"workspace": { "id": "0199…", "slug": "northwind" },
"actor": { "id": "0199…", "name": "Mara Lindqvist" },
"data": { "id": "0199…", "key": "PRD-12", "title": "Write release notes", "board_id": "…", "list_id": "…", "list": { "id": "…", "name": "Doing" }, "…": "…" },
"changes": { "list": { "from": "To do", "to": "Doing" } }
}
ides el id de la entrega (también enX-Sol2flow-Delivery): úsalo para ignorar duplicados, porque la entrega es al menos una vez.datatiene la forma que devuelve la API REST para ese recurso (consulta el documento OpenAPI): Task =TaskSummarymáslistydeleted; Comment sinrepliesmástask: { id, key, title, board_id }; Board sin elrolede cada usuario; TimeEntry mástask; Member ={ user: { id, name, username }, role }.- Privacidad: ninguna dirección de correo en ningún sitio;
actoresnullen los cambios del sistema. - Los contenidos de más de 64 KB se recortan: se quitan los documentos de texto enriquecido (
description,doc) (su texto plano se mantiene), se acortan las cadenas largas y se añade"truncated": true. - El contenido se genera cuando se produce el cambio (dentro de su transacción), así que refleja el estado justo después del cambio, aunque la entrega se reintente horas más tarde.
Entrega
Cada entrega es un POST con Content-Type: application/json, User-Agent: sol2flow-webhooks/1.0 y:
| Cabecera | |
|---|---|
X-Sol2flow-Event | el nombre del evento |
X-Sol2flow-Delivery | el id de la entrega (= id del contenido) |
X-Sol2flow-Webhook | el id del webhook |
X-Sol2flow-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256(secret, t + "." + body)> |
- Éxito: cualquier 2xx en 10 segundos. Las redirecciones no se siguen (un 3xx cuenta como fallo). Solo se leen y se guardan los primeros 2 KB de la respuesta.
- Reintentos tras un error de red, un tiempo de espera agotado, un 5xx, un 408 o un 429: a los 1 min, 5 min, 30 min,
2 h, 6 h y 12 h (7 intentos en unas 21 horas); después, la entrega queda
failed. Los demás 4xx (y los 3xx) son definitivos de inmediato. - Desactivación automática: tras 20 entregas fallidas seguidas (cada una tras todos sus reintentos, o definitiva de inmediato) el webhook se desactiva y los admins del espacio reciben una notificación en la app. Al volver a activarlo se reinicia el contador y se envían las entregas que estaban en espera. Los pings de prueba no cuentan.
- Los webhooks en pausa no registran eventos nuevos; las entregas que ya estaban en cola esperan y se envían al reanudarlo.
- Conservación: la tarea de limpieza diaria elimina las entregas de más de 30 días.
Orden y duplicados
Los eventos se registran junto con el cambio que los provoca: un cambio que falla no envía nada. Las entregas pueden
llegar más de una vez y, tras los reintentos, desordenadas: usa el id del contenido para ignorar duplicados y
created_at para ordenarlas.
Verificar la firma
Calcula el HMAC sobre el cuerpo sin procesar de la petición (antes de interpretar el JSON), compáralo en tiempo constante y rechaza las marcas de tiempo antiguas (reenvíos maliciosos).
Node.js (Express):
import crypto from 'node:crypto';
import express from 'express';
const SECRET = process.env.SOL2FLOW_WEBHOOK_SECRET; // whsec_…
const app = express();
app.post('/sol2flow', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('X-Sol2flow-Signature') ?? '';
const { t, v1 } = Object.fromEntries(header.split(',').map(p => p.split('=')));
const expected = crypto.createHmac('sha256', SECRET).update(`${t}.${req.body}`).digest('hex');
const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300;
const ok = /^[0-9a-f]{64}$/.test(v1 ?? '') && crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(v1, 'hex'));
if (!ok || !fresh) return res.sendStatus(401);
const event = JSON.parse(req.body);
// … handle event.event / event.data; ignore event.id values you've seen before
res.sendStatus(204);
});
Python (Flask):
import hashlib, hmac, os, time
from flask import Flask, request, abort
SECRET = os.environ["SOL2FLOW_WEBHOOK_SECRET"].encode() # whsec_…
app = Flask(__name__)
@app.post("/sol2flow")
def sol2flow():
parts = dict(p.split("=", 1) for p in request.headers.get("X-Sol2flow-Signature", "").split(",") if "=" in p)
t, v1 = parts.get("t", ""), parts.get("v1", "")
body = request.get_data() # raw bytes
expected = hmac.new(SECRET, f"{t}.".encode() + body, hashlib.sha256).hexdigest()
if not t.isdigit() or abs(time.time() - int(t)) > 300 or not hmac.compare_digest(expected, v1):
abort(401)
event = request.get_json()
# … handle event["event"], event["data"]
return "", 204
La clave es la cadena completa del secreto, incluido whsec_, como bytes UTF-8.
Direcciones de destino permitidas
Una URL de webhook hace que el servidor envíe peticiones, así que podría apuntarse a servicios internos. Las reglas:
- Solo
http:yhttps:; sinuser:password@; como máximo 2000 caracteres. - Se resuelve el host y todas las direcciones deben estar permitidas; las IP literales se comprueban igual.
- Rechazadas salvo que la instancia permita destinos privados: loopback
127.0.0.0/8,::1; privadas10/8,172.16/12,192.168/16, unique-localfc00::/7; link-local169.254/16,fe80::/10; CGNAT100.64/10; rangos de documentación y de benchmarking. Las direcciones IPv6 que contienen una IPv4 (IPv4-mapped::ffff:…, NAT6464:ff9b::/96, 6to42002::/16) se evalúan por esa dirección IPv4. - Siempre rechazadas:
0.0.0.0/8,::, multicast, broadcast,240/4y los endpoints de metadatos de la nube169.254.169.254,169.254.170.2,100.100.100.200,fd00:ec2::254, aunque se permitan destinos privados. - Se comprueba al guardar (422
webhook_url_blocked,webhook_url_unresolved,webhook_url_invalid) y otra vez antes de cada intento (DNS rebinding): la petición se envía entonces exactamente a la dirección comprobada (la IP resuelta se fija mediante ellookupdel socket, con el nombre de host original paraHosty TLS/SNI). Una dirección bloqueada hace fallar la entrega sin reintento; una resolución DNS fallida se reintenta.