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 pasa algo en tu espacio de trabajo, 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: si el dominio
ahora resuelve a una dirección privada, esa entrega se marca como bloqueada en
vez de reintentarse, porque ningún reintento cambia a dónde resuelve el
nombre. Si la resolución simplemente falla en ese momento (tu DNS tuvo un mal
minuto), lo tratamos como algo pasajero y esa entrega se reintenta como
cualquier otro fallo. Una redirección tampoco se sigue: un 3xx cuenta como
intento fallido.
Los eventos
Cada evento pertenece a una app, y el formulario los agrupa por ella. Estos los emite Swarm.
| 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.
Una excepción: un 4xx que no sea 429 no se reintenta. Un error de contrato
repetido seis veces son seis rechazos iguales, así que lo archivamos como
fallido en el primer intento en vez de reintentarlo. Todo lo demás cuenta como
un mal minuto y recibe la escalera completa: un 5xx, un 429, una respuesta
que tarde más de 5 segundos, un error de conexión y una resolución DNS que
falla.
Después de 20 fallos seguidos deshabilitamos el endpoint y lo decimos en la lista. Ese contador solo avanza una vez por evento, en el intento que finalmente lo da por perdido, ya sea agotando la escalera de reintentos o un error de contrato inmediato. Nunca avanza una vez por reintento, así que un evento que pasa por sus seis intentos nunca cuesta más de un solo strike. Reanuda un endpoint deshabilitado desde el menú de la fila cuando tu servidor esté listo: eso pone el contador en cero. Cualquier respuesta correcta también lo reinicia.
Un webhook pausado no acumula nada: mientras está en pausa los eventos no se entregan ni se recuperan al reanudarlo, y los que alcancen a quedar en cola se marcan Omitido en la lista de entregas.
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.