Webhooks

En vez de preguntar cada minuto si algo ha cambiado, deja que el proyecto te avise. Cada envío va firmado para que puedas comprobar que viene de aquí.

Configurar un endpoint

En Ajustes del proyecto → Webhooks. Necesitas ser propietario. La URL debe ser https y pública: se rechazan localhost y los rangos privados, porque quien hace la petición es nuestro servidor, no tu navegador.

Al crearlo se muestra el secreto de firma. Guárdalo: es lo que te permite verificar cada envío.

Eventos

Puedes suscribirte a algunos o dejarlo sin marcar para recibirlos todos.

Las dependencias tienen eventos propios en vez de llegar como task.updated: son el único cambio que no toca ningún campo de la tarea, así que quien escuchara solo task.updated nunca se enteraría de lo que decide si el trabajo puede empezar.

Ejemplo
task.created         una tarea nueva
task.updated         cambió algún campo (llega la lista en "changed")
task.deleted         fue a la papelera
task.restored        volvió de la papelera
dependency.added     una tarea pasó a esperar a otra
dependency.removed   dejó de esperarla
comment.created      comentario nuevo en una tarea
sprint.closed        se cerró un sprint

Forma del envío

Un POST con cuerpo JSON. El payload es pequeño a propósito: te dice qué pasó y sobre qué, y la API queda para pedir el detalle cuando lo necesites.

Ejemplo
{
  "event": "task.updated",
  "deliveryId": "0f5c…",
  "sentAt": "2026-07-27T09:14:02.511Z",
  "projectId": "3ab9…",
  "data": {
    "taskId": "9b2f…",
    "title": "Preparar la demo",
    "changed": ["status", "assignee"]
  }
}

Verificar la firma

Cada petición lleva x-planely-signature, x-planely-timestamp, x-planely-event y x-planely-delivery. La firma es un HMAC SHA-256 sobre {timestamp}.{cuerpo} con tu secreto.

Compárala en tiempo constante y rechaza lo que llegue con un timestamp muy viejo: eso es lo que impide que alguien reenvíe una petición que capturó antes.

Ejemplo
import { createHmac, timingSafeEqual } from "node:crypto"

export function verify(req: Request, rawBody: string, secret: string) {
  const signature = req.headers.get("x-planely-signature") ?? ""
  const timestamp = req.headers.get("x-planely-timestamp") ?? ""

  // Descarta reenvíos de hace más de cinco minutos.
  const age = Math.abs(Date.now() / 1000 - Number(timestamp))
  if (!timestamp || age > 300) return false

  const expected =
    "sha256=" +
    createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex")

  const a = Buffer.from(signature)
  const b = Buffer.from(expected)
  return a.length === b.length && timingSafeEqual(a, b)
}

Entrega y reintentos

Respondemos rápido y esperamos poco: cada intento corta a los 5 segundos y se reintenta hasta 3 veces con una espera corta. Contesta 2xx en cuanto recibas el evento y haz el trabajo pesado después — si tardas, cuenta como fallo.

No hay cola durable. Si tu endpoint está caído durante esos intentos, ese evento se pierde: los envíos salen dentro de la petición que los provocó y nunca la bloquean ni la hacen fallar. El último resultado queda a la vista en Ajustes, que es donde se detecta un endpoint que ha dejado de responder. Trata los webhooks como un aviso, no como una fuente de verdad: para eso está la API.

Los envíos se identifican con deliveryId. Un reintento repite el mismo id, así que úsalo para no procesar dos veces el mismo evento.