Webhooks
Eel te avisa cuando algo pasa, firmado, en tu propio servidor.
Un webhook es una llamada que Eel te hace a ti. Cada vez que alguien mueve una tarjeta o escribe un mensaje, enviamos ese evento a la dirección que nos des, en menos de un minuto, sin que preguntes nada.
Cómo se crea
En Nest, en Webhooks. Lo crean propietarios y administradores del espacio de trabajo, y solo ellos lo ven: la dirección de un webhook dice a qué servidor está llegando una copia de todo lo que pasa dentro.
El formulario pide tres cosas: la dirección https://, una descripción y los
eventos que quieres recibir. Si no marcas ninguno, recibes todos, incluidos los
que agreguemos más adelante.
Al crearlo verás el secreto de firma una sola vez. Guárdalo antes de cerrar la ventana: con él verificas que cada evento viene de nosotros.
Un webhook recibe los eventos de todo el espacio de trabajo, mensajes directos incluidos. Apúntalo a un servidor tuyo y a ninguno más.
La dirección
Aceptamos https:// y nada más, incluso dentro de una red privada. Rechazamos
la dirección al crearla, con el motivo, cuando:
- no empieza por
https - lleva usuario o contraseña
- apunta a un puerto reservado
- el dominio no resuelve
- el dominio resuelve a una dirección privada o de loopback
La misma comprobación corre otra vez en el momento de enviar, así que un
dominio que cambie de dirección deja de recibir. Una redirección tampoco se
sigue: un 3xx cuenta como intento fallido.
Los eventos
| Tipo | Cuándo llega |
|---|---|
task.created |
alguien crea una tarjeta, venga de donde venga |
task.updated |
cambia el título, la descripción, la prioridad o la fecha |
task.moved |
la tarjeta cambia de columna |
task.assigned |
cambia quién es responsable de la tarjeta |
task.comment.created |
alguien comenta una tarjeta |
channel.message.created |
alguien escribe en un canal o en un mensaje directo |
task.moved y task.assigned van aparte de task.updated para que no tengas
que leer un diff para enterarte de un movimiento.
No hay un evento de mención. Un mensaje produce una entrega, y
data.message.mentions[] trae cada mención con su persona, más las difusiones
a @channel y @here.
El botón Probar manda un evento ping, por la misma cola y con la misma
firma que los demás.
El sobre
Todo evento llega con la misma forma:
{
"id": "6f5b5bd1-3a5e-4a6b-9c2f-2f6d1b0b4a11",
"type": "task.moved",
"version": 1,
"app": "swarm",
"workspace_id": "364d2a36-934a-42bc-919b-bb7233d2f024",
"occurred_at": "2026-09-09T14:02:11.412Z",
"actor": { "user_id": "0a1c…", "display_name": "Sofía", "kind": "USER" },
"data": {
"task": {
"id": "b1c2…",
"number": 412,
"title": "Revisar la propuesta",
"url": "https://swarm.eel.software/w/acme/t/412",
"project": { "id": "9d0e…", "name": "Ventas" },
"status": { "id": "3f4a…", "name": "En curso", "is_done": false },
"assignee": { "user_id": "0a1c…", "display_name": "Sofía" },
"labels": [{ "id": "77aa…", "name": "urgente", "color": "red" }],
"priority": "HIGH",
"due_date": "2026-09-12",
"completed_at": null
},
"from_status": { "id": "1a2b…", "name": "Pendiente", "is_done": false },
"to_status": { "id": "3f4a…", "name": "En curso", "is_done": false }
}
}
La tarjeta viene completa dentro del sobre para que no tengas que llamarnos de vuelta por el nombre de un proyecto o de una columna.
version es el contrato de data. Sube solo si cambiamos algo de forma
incompatible; agregar un campo no lo mueve, así que tu código debe ignorar los
campos que no conozca.
Las cabeceras
POST /tu-endpoint HTTP/1.1
Content-Type: application/json
User-Agent: Eel-Webhooks/1
X-Eel-Event: task.moved
X-Eel-Delivery: 8f1d3b90-6d2e-4a3f-8c11-9b0a2d4e6f77
X-Eel-Event-Id: 6f5b5bd1-3a5e-4a6b-9c2f-2f6d1b0b4a11
X-Eel-Attempt: 1
X-Eel-Timestamp: 1789041731
X-Eel-Signature: v1=8b1a…
X-Eel-Event-Id es el mismo del campo id del sobre: no cambia entre
reintentos ni entre dos webhooks que reciban el mismo evento. Ese es tu
identificador para no procesar dos veces lo mismo. X-Eel-Delivery cambia en
cada intento y en cada endpoint, así que no sirve para eso.
Verificar la firma
La firma es un HMAC-SHA256 sobre el texto <timestamp>.<cuerpo>, con el
secreto sin el prefijo whsec_ como clave. Compara en tiempo constante y
rechaza lo que llegue con más de cinco minutos de diferencia.
En Node:
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verify(rawBody, headers, secret) {
const timestamp = headers['x-eel-timestamp']
const signature = headers['x-eel-signature']
if (!timestamp || !signature) return false
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp))
if (!Number.isFinite(age) || age > 300) return false
const key = secret.startsWith('whsec_') ? secret.slice(6) : secret
const expected = 'v1=' + createHmac('sha256', key).update(`${timestamp}.${rawBody}`).digest('hex')
const a = Buffer.from(expected)
const b = Buffer.from(signature)
return a.length === b.length && timingSafeEqual(a, b)
}
En Python:
import hashlib
import hmac
import time
def verify(raw_body: bytes, headers, secret: str) -> bool:
timestamp = headers.get("X-Eel-Timestamp")
signature = headers.get("X-Eel-Signature")
if not timestamp or not signature:
return False
if abs(int(time.time()) - int(timestamp)) > 300:
return False
key = secret[6:] if secret.startswith("whsec_") else secret
signed = f"{timestamp}.".encode() + raw_body
expected = "v1=" + hmac.new(key.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
Firma el cuerpo tal como llega, byte por byte. Si tu framework lo parsea y lo vuelve a serializar antes de que lo veas, la firma no va a cuadrar: lee el cuerpo crudo.
Reintentos
Respondemos a un 2xx archivando la entrega y ahí termina. Cualquier otra cosa
se reintenta hasta seis veces, esperando 30 segundos, 2 minutos, 10 minutos, 1
hora y 6 horas entre intentos. Con eso, un servidor que estuvo caído ocho horas
recupera sus eventos.
Dos excepciones:
- un
4xxque no sea429no se reintenta. Un error de contrato repetido seis veces son seis rechazos iguales. - una respuesta que tarde más de 5 segundos cuenta como fallo.
Después de 20 fallos seguidos deshabilitamos el endpoint y lo decimos en la lista. Reanúdalo desde el menú de la fila cuando tu servidor esté listo: eso pone el contador en cero. Cualquier respuesta correcta también lo reinicia.
Ver qué pasó
El menú de cada fila abre Ver entregas: los últimos 50 intentos con su
evento, su estado, el número de intento, el código que devolvió tu servidor y
la hora. Reenviar vuelve a mandar ese mismo evento, con las mismas bytes y
por tanto el mismo X-Eel-Event-Id, con una firma nueva.
Guardamos el historial 14 días.
Rotar el secreto
Rotar secreto genera uno nuevo y lo muestra una sola vez. El anterior deja de servir de inmediato, así que hazlo cuando tengas dónde pegar el nuevo.