Saltar al contenido principal

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​

  1. 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).
  2. 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.
  3. 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​

EventoCuándodata
task.createdse crea una tarea (en la app o por la API)Task
task.updatedtítulo, descripción, fechas, etiquetas, asignados, miembros, elementos de checklist marcados / desmarcadosTask (+ changes.fields)
task.moveda otra lista (changes.list: {from, to}: nombres de las listas) o a otro tablero (changes.board, changes.key.from)Task
task.archived / task.restoredarchivada / restaurada; task.restored también cuando se deshace una eliminaciónTask
task.deletedeliminada (movida a la papelera, deleted: true)Task
task_link.added / task_link.removedse 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.deletedcomentarios y respuestas; un comentario restaurado es comment.updatedComment (+ task)
board.created / board.updated / board.archived / board.restoredtableros; updated incluye nombre, acceso, enlace público, miembros y listas (changes.fields)Board
member.added / member.removedalguien 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 quitadoMember
time_entry.created / time_entry.updated / time_entry.deletedse registra tiempo o se detiene un cronómetro; se modifica; se elimina (los cronómetros en marcha no se notifican)TimeEntry (+ task)
pingsolo "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" } }
}
  • id es el id de la entrega (también en X-Sol2flow-Delivery): úsalo para ignorar duplicados, porque la entrega es al menos una vez.
  • data tiene la forma que devuelve la API REST para ese recurso (consulta el documento OpenAPI): Task = TaskSummary más list y deleted; Comment sin replies más task: { id, key, title, board_id }; Board sin el role de cada usuario; TimeEntry más task; Member = { user: { id, name, username }, role }.
  • Privacidad: ninguna dirección de correo en ningún sitio; actor es null en 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-Eventel nombre del evento
X-Sol2flow-Deliveryel id de la entrega (= id del contenido)
X-Sol2flow-Webhookel id del webhook
X-Sol2flow-Signaturet=<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: y https:; sin user: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; privadas 10/8, 172.16/12, 192.168/16, unique-local fc00::/7; link-local 169.254/16, fe80::/10; CGNAT 100.64/10; rangos de documentación y de benchmarking. Las direcciones IPv6 que contienen una IPv4 (IPv4-mapped ::ffff:…, NAT64 64:ff9b::/96, 6to4 2002::/16) se evalúan por esa dirección IPv4.
  • Siempre rechazadas: 0.0.0.0/8, ::, multicast, broadcast, 240/4 y los endpoints de metadatos de la nube 169.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 el lookup del socket, con el nombre de host original para Host y TLS/SNI). Una dirección bloqueada hace fallar la entrega sin reintento; una resolución DNS fallida se reintenta.