Desarrolladores
Webhooks Pro
Los webhooks envían un POST HTTP a una URL tuya cada vez que pasa algo en un espacio de trabajo: se crea una página, una tarjeta cambia de lista, alguien escribe en un chat de grupo. Sirven para mantener otros sistemas al día, lanzar automatizaciones o publicar avisos en Slack sin escribir código.
Cómo funcionan los webhooks
- Saltan con los cambios hechos desde cualquier sitio: la app, el móvil, la API, un plugin o un asistente de IA a través de MCP.
- Pertenecen a un espacio de trabajo. Cada webhook tiene una URL, una lista de eventos y su propio secreto de firma.
- Cada entrega va firmada, para que tu servidor compruebe que viene de verdad de Tylode.
- El cuerpo trae un campo
textya redactado, así que la URL de un Incoming Webhook de Slack funciona directamente. - Un webhook que falla nunca frena ni bloquea lo que la persona estaba haciendo en Tylode.
Los webhooks se configuran en la app; no hay ningún endpoint de la API para crearlos o gestionarlos.
Crear un webhook
Tienes que ser owner o admin del espacio, y el espacio tiene que estar en Pro o Premium. Para crear o reactivar un webhook, tu propia cuenta también tiene que estar en Pro (el plan efectivo es el menor entre el tuyo y el del espacio).
- Abre Ajustes → API y webhooks → Webhooks (ir allí).
- Ponle un nombre, por ejemplo «Slack #general».
- Pega la URL que recibirá los eventos.
- Pulsa los eventos que quieras. Página creada y Tarjeta movida de lista vienen marcados. Elige al menos uno.
- Pulsa Crear webhook.
- Pulsa Probar para comprobar que tu receptor lo acepta.
La URL tiene que:
- Empezar por
https://. No se admitehttpa secas. - Apuntar a un servidor público. Se rechazan direcciones como
localhost,127.0.0.1,10.x.x.x,192.168.x.x, de172.16.x.xa172.31.x.x,169.254.x.xy los nombres que acaban en.local,.internal,.localdomain,.homeo.lan.
Para probar webhooks mientras desarrollas en tu ordenador, expón tu servidor local con un túnel HTTPS (como ngrok o Cloudflare Tunnel) y usa la URL pública que te dé.
Eventos
| Evento | Nombre en la app | Cuándo salta | Campos de data |
|---|---|---|---|
page.created | Página creada | Se crea una página. Las plantillas no cuentan. | page_id, title, created_by, parent_page_id |
page.updated | Página renombrada | Cambia el título de una página. Editar el contenido no lo dispara. | page_id, title, previous_title |
page.deleted | Página borrada | Una página se borra para siempre (al vaciarla de la papelera), no cuando se manda a la papelera. | page_id, title |
card.created | Tarjeta creada | Se crea una tarjeta en un tablero. | card_id, board_id, list_id, title, list_name, board_name |
card.moved | Tarjeta movida de lista | Una tarjeta cambia de lista. Reordenar dentro de la misma lista no lo dispara. | card_id, board_id, from_list_id, to_list_id, title, list_name (la lista nueva), board_name |
message.created | Mensaje en un grupo | Alguien escribe en una conversación de grupo. Los mensajes directos nunca salen. | message_id, conversation_id, conversation_name, sender_id, body |
member.joined | Alguien se une al workspace | Una persona pasa a ser miembro del espacio. | user_id, role |
comment.created | Comentario en una página | Alguien comenta en un bloque de una página. | comment_id, page_id, block_id, page_title, body, created_by |
test | (el botón Probar) | Pulsas Probar. | subscription_id, name |
Los IDs son UUID. created_by, sender_id y user_id son IDs de usuario. role es owner, admin, member o viewer.
Contenido de la entrega
Cada entrega es un POST con Content-Type: application/json y este sobre:
| Campo | Tipo | Descripción |
|---|---|---|
id | string (UUID) | ID único de esta entrega. Es el mismo que la cabecera X-Verso-Delivery. |
event | string | El nombre del evento, como card.moved. |
workspace_id | string (UUID) | El espacio donde ha pasado. |
occurred_at | string | Cuándo ha pasado, en ISO 8601 y UTC. |
text | string | Una frase ya redactada que describe el evento, en español, para herramientas de chat como Slack. No la analices; usa data. |
data | object | Los detalles del evento. Los campos dependen del evento (mira la tabla de arriba). |
Tylode envía el JSON en una sola línea. Los ejemplos de abajo están formateados para leerlos mejor y mantienen el orden en que llegan las claves, del que no deberías depender.
page.created
{
"id": "5f0c2e9a-7b1d-4c3e-9a8f-2d6b4e1c7a90",
"data": {
"title": "Q4 roadmap",
"page_id": "2b9d6f1e-8a4c-4f3b-b7e2-51c0d9a8e6f4",
"created_by": "9d3f7a21-4c6e-4b8a-a1f2-3e5d7c9b0a14",
"parent_page_id": null
},
"text": "Ana García creó la página «Q4 roadmap»",
"event": "page.created",
"occurred_at": "2026-09-23T08:30:12.418532+00:00",
"workspace_id": "7c1e2a4b-3f5d-4e8a-9b21-6d0f5c8e1a37"
}card.moved
{
"id": "b81e4d2c-6a3f-4f0e-8c95-1d7a2e6b3f48",
"data": {
"title": "Review contract",
"card_id": "c4e7a1b9-2d5f-4a8c-b3e6-0f9d1a7c5e22",
"board_id": "a3b5c7d9-1e2f-4a6b-8c0d-2e4f6a8b0c13",
"list_name": "Done",
"board_name": "Launch",
"to_list_id": "f1a3c5e7-9b2d-4f6a-8c1e-3b5d7f9a1c35",
"from_list_id": "e0f2a4c6-8d1b-4e5f-9a7c-2b4d6f8a0c24"
},
"text": "Ana García movió «Review contract» a Done",
"event": "card.moved",
"occurred_at": "2026-09-23T09:02:47.105328+00:00",
"workspace_id": "7c1e2a4b-3f5d-4e8a-9b21-6d0f5c8e1a37"
}message.created
{
"id": "0d6f3b8e-2c4a-4e1f-b7d9-5a8c1e3f6b71",
"data": {
"body": "Shall we review the demo at 5?",
"sender_id": "9d3f7a21-4c6e-4b8a-a1f2-3e5d7c9b0a14",
"message_id": "3e5a7c9e-1b3d-4f5a-9c7e-4d6f8a0b2c46",
"conversation_id": "6c8e0a2c-4d6f-4a8b-8e0a-5f7b9d1e3a57",
"conversation_name": "Product team"
},
"text": "Ana García en Product team: Shall we review the demo at 5?",
"event": "message.created",
"occurred_at": "2026-09-23T09:15:03.772910+00:00",
"workspace_id": "7c1e2a4b-3f5d-4e8a-9b21-6d0f5c8e1a37"
}En text, el mensaje se recorta a sus primeros 280 caracteres; data.body siempre lleva el mensaje completo.
test
{
"id": "7a9c1e3a-5b7d-4f9a-8c2e-6d8f0b2d4e68",
"data": {
"name": "Slack #general",
"subscription_id": "8b0d2f4b-6c8e-4a0b-9d3f-7e9a1c3e5f79"
},
"text": "Evento de prueba desde Tylode. Si lees esto, el webhook funciona.",
"event": "test",
"occurred_at": "2026-09-23T09:20:00.051244+00:00",
"workspace_id": "7c1e2a4b-3f5d-4e8a-9b21-6d0f5c8e1a37"
}Resto de eventos
Los demás eventos usan el mismo sobre. Su data tiene esta forma:
{ "title": "Q4 plan", "page_id": "2b9d6f1e-8a4c-4f3b-b7e2-51c0d9a8e6f4", "previous_title": "Q4 roadmap" }{ "title": "Old draft", "page_id": "2b9d6f1e-8a4c-4f3b-b7e2-51c0d9a8e6f4" }{ "title": "Review contract", "card_id": "c4e7a1b9-2d5f-4a8c-b3e6-0f9d1a7c5e22", "list_id": "e0f2a4c6-8d1b-4e5f-9a7c-2b4d6f8a0c24", "board_id": "a3b5c7d9-1e2f-4a6b-8c0d-2e4f6a8b0c13", "list_name": "To do", "board_name": "Launch" }{ "role": "member", "user_id": "4f6a8c0e-2b4d-4f6a-8c0e-2b4d6f8a0c91" }{ "body": "Can we close this on Friday?", "page_id": "2b9d6f1e-8a4c-4f3b-b7e2-51c0d9a8e6f4", "block_id": "d2f4a6c8-0e1b-4d3f-a5c7-9e1b3d5f7a02", "comment_id": "5b7d9f1b-3c5e-4a7c-9e1a-3c5e7a9c1e84", "created_by": "9d3f7a21-4c6e-4b8a-a1f2-3e5d7c9b0a14", "page_title": "Q4 plan" }Por orden: page.updated, page.deleted, card.created, member.joined y comment.created.
Cabeceras
| Cabecera | Valor |
|---|---|
Content-Type | application/json |
X-Verso-Event | El nombre del evento, como page.created. |
X-Verso-Delivery | El ID de la entrega, el mismo que id en el cuerpo. |
X-Verso-Signature | sha256= seguido del HMAC-SHA256 del cuerpo crudo, en hexadecimal. |
Estos nombres de cabecera son estables.
Verificar la firma
Cualquiera que conozca tu URL podría mandarle eventos falsos, así que comprueba la firma antes de fiarte de una entrega:
- Busca el secreto de firma: en Ajustes → API y webhooks → Webhooks, pulsa Detalles en el webhook y luego Ver junto a Secreto de firma. Es una cadena hexadecimal de 48 caracteres.
- Calcula el HMAC-SHA256 del cuerpo crudo de la petición, tal cual llega, con el secreto como clave. Usa el secreto como texto normal; no lo decodifiques desde hexadecimal.
- Escribe el resultado en hexadecimal en minúsculas y ponle
sha256=delante. - Compáralo con
X-Verso-Signatureusando una comparación de tiempo constante.
Node.js (Express)
import express from "express";
import crypto from "node:crypto";
const app = express();
const SECRET = process.env.TYLODE_WEBHOOK_SECRET; // el secreto de firma de 48 caracteres
function isValidSignature(rawBody, header) {
const expected =
"sha256=" + crypto.createHmac("sha256", SECRET).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(header ?? "");
// timingSafeEqual lanza un error si las longitudes difieren, así que se comprueban antes.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// express.raw deja el cuerpo como Buffer, tal cual ha llegado.
app.post("/webhooks/tylode", express.raw({ type: "application/json" }), (req, res) => {
if (!isValidSignature(req.body, req.get("X-Verso-Signature"))) {
return res.status(401).send("Invalid signature");
}
const event = JSON.parse(req.body.toString("utf8"));
// Responde primero y haz después el trabajo lento: Tylode espera 5 segundos como mucho.
res.sendStatus(204);
handleEvent(event).catch(console.error);
});
async function handleEvent(event) {
switch (event.event) {
case "card.moved":
console.log(`${event.data.title} → ${event.data.list_name}`);
break;
case "test":
console.log("Test delivery received");
break;
}
}
app.listen(3000);No pongas express.json() delante de esta ruta: se come el cuerpo antes de que puedas firmarlo.
Python (Flask)
import hashlib
import hmac
import os
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["TYLODE_WEBHOOK_SECRET"].encode() # el secreto de firma de 48 caracteres
@app.post("/webhooks/tylode")
def tylode_webhook():
raw = request.get_data() # bytes crudos, antes de analizar el JSON
expected = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
received = request.headers.get("X-Verso-Signature", "")
if not hmac.compare_digest(expected, received):
abort(401)
event = request.get_json()
if event["event"] == "page.created":
print("New page:", event["data"]["title"])
return "", 204Route handler de Next.js
// app/api/webhooks/tylode/route.ts
import crypto from "node:crypto";
export async function POST(request: Request) {
const raw = await request.text(); // cuerpo crudo, antes de analizarlo
const expected =
"sha256=" +
crypto.createHmac("sha256", process.env.TYLODE_WEBHOOK_SECRET!).update(raw).digest("hex");
const received = request.headers.get("x-verso-signature") ?? "";
const a = Buffer.from(expected);
const b = Buffer.from(received);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return new Response("Invalid signature", { status: 401 });
}
const event = JSON.parse(raw);
// Que sea rápido: Tylode espera 5 segundos como mucho.
console.log(event.event, event.id);
return new Response(null, { status: 204 });
}Entregas y fallos
- Tiempo de espera: Tylode espera tu respuesta hasta 5 segundos. Contesta con cualquier estado
2xxen cuanto hayas comprobado la firma y haz el trabajo lento después (una cola, una tarea en segundo plano). - Sin reintentos: cada evento se envía una vez. Si tu servidor está caído o responde con un error, esa entrega se pierde. Si no puedes perderte nada, sincroniza además de vez en cuando con la API.
- Orden: las entregas pueden llegar desordenadas. Usa
occurred_atcuando el orden importe. - Últimas entregas: pulsa Detalles en un webhook para ver sus 25 últimas entregas, cada una con su evento, su hora y su resultado: el código que devolvió tu servidor, un error o En curso mientras espera. La respuesta puede tardar hasta un minuto en aparecer. Si al cabo de una hora no se ha registrado nada, la entrega cuenta como fallida.
- Estado de un vistazo: cada webhook muestra OK · 200, Error 500, Sin respuesta, Sin entregas aún o Desactivado.
- Desactivación automática: tras 10 entregas fallidas seguidas (todo lo que no sea
2xx, incluidos los tiempos de espera agotados), el webhook se desactiva. Arregla el receptor y pulsa Reactivar para volver a encenderlo. - Historial: las entregas se guardan 30 días.
- Plan: si el espacio baja de Pro, sus webhooks siguen configurados, pero no se envía nada hasta que vuelva a Pro.
Probar, pausar y rotar
- Probar envía un evento
testa la URL. Es la forma más rápida de comprobar la URL, la firma y tu cortafuegos. Abre Detalles al cabo de unos segundos para ver el resultado. - Pausar detiene las entregas; Reactivar las vuelve a poner en marcha. Lo que pase mientras un webhook está en pausa no se envía nunca.
- Rotar (en Detalles, junto a Secreto de firma) crea un secreto nuevo que se usa desde la siguiente entrega. El anterior deja de valer al momento, así que actualízalo enseguida en tu receptor o rechazará lo que llegue.
- Borrar (el icono de la papelera; pulsa otra vez para confirmar) elimina el webhook y su historial.
Para cambiar la URL o los eventos, crea un webhook nuevo y borra el antiguo.
Receta: publicar en un canal de Slack
Los Incoming Webhooks de Slack leen el campo text y lo publican en un canal, así que no necesitas código en medio.
- En Slack, crea una app para tu espacio de Slack en api.slack.com/apps, abre Incoming Webhooks y actívalos.
- Pulsa Add New Webhook, elige el canal y copia la URL. Tiene esta forma:
https://hooks.slack.com/services/T000/B000/XXXX. - En Tylode, abre Ajustes → API y webhooks → Webhooks, ponle nombre (por ejemplo «Slack #general»), pega la URL de Slack, elige los eventos y pulsa Crear webhook.
- Pulsa Probar. En el canal debería aparecer el mensaje «Evento de prueba desde Tylode. Si lees esto, el webhook funciona.».
Slack no comprueba la firma y publica solo la frase de text, que está en español. Si quieres otro idioma, otro formato o filtrar eventos (por ejemplo, solo las tarjetas que llegan a «Hecho»), apunta el webhook a un pequeño servidor tuyo, verifica la firma, monta tu mensaje con data y reenvíalo a Slack.
Evitar duplicados
Tylode no reenvía entregas, pero tu propia infraestructura sí puede hacerlo: un proxy que reintenta, una cola que vuelve a entregar, un manejador que se ejecuta dos veces. Haz tu manejador idempotente guardando el ID de la entrega (id en el cuerpo, o la cabecera X-Verso-Delivery) y saltándote los que ya has procesado:
const seen = new Set(); // en producción, usa una base de datos o Redis
function alreadyProcessed(event) {
if (seen.has(event.id)) return true;
seen.add(event.id);
return false;
}Páginas relacionadas
- Plataforma para desarrolladores: la API, para sincronizar o actuar sobre los eventos.
- Recetas: automatizaciones listas para usar.
- Miembros y roles: quién puede gestionar los webhooks.