Webhooks: un aviso firmado cuando se crea o se comparte un documento

Menú de la cuenta → Webhooks registra una URL que recibe un POST firmado para dos eventos, document.created y document.shared. Se gestiona solo desde una sesión iniciada, y es a propósito: queda fuera de la API programable, de modo que una clave filtrada no puede convertirse en un flujo permanente de todos los documentos que vengan después.

En una cuenta ocurren dos cosas de las que otro programa puede querer enterarse en el momento mismo en que pasan: se ha creado un documento y se ha compartido un documento. En Menú de la cuenta → Webhooks es donde indicas una URL https:// que debe oírlas, y donde lees el secreto con el que se firman sus entregas.

Qué llega

Un POST cuyo cuerpo JSON tiene tres claves — el evento, la hora y los datos:

{
  "event": "document.created",
  "created_at": "2026-09-11T09:12:44.000Z",
  "data": { "id": "…", "name": "release-notes.md", "kind": "markdown-to-html", "size": 4193 }
}

document.shared lleva el id y el name, el mode en el que queda el documento, la url para compartir y notified, las direcciones a las que realmente salió un aviso. Ninguno de los dos eventos lleva el texto del documento: un receptor que lo necesite tiene el identificador y una clave de API.

Comprobar la firma

Cada entrega trae una cabecera x-transformpipe-signature con la forma t=<segundos unix>,v1=<hex>, donde v1 es un HMAC-SHA256 sobre la cadena <t>.<cuerpo>, con el secreto de este webhook como clave — la misma forma que usan Stripe y GitHub, así que el código de verificación que ya tengas suele necesitar solo un secreto distinto.

import { createHmac, timingSafeEqual } from 'node:crypto';

// `raw` es el cuerpo tal como llegó. Una copia analizada y vuelta a codificar no da el mismo hash.
export function verify(raw, header, secret) {
  const [t, v1] = header.split(',').map((part) => part.split('=')[1]);
  const want = createHmac('sha256', secret).update(`${t}.${raw}`).digest('hex');

  return (
    v1.length === want.length &&
    timingSafeEqual(Buffer.from(v1, 'hex'), Buffer.from(want, 'hex'))
  );
}

Rechaza una t de hace más de unos minutos y una entrega capturada no podrá reproducírsete más tarde. El secreto empieza por whsec_ y puede volver a leerse en el diálogo cuando lo necesites: a diferencia de una clave de API, te lo mostramos nosotros, así que verlo dos veces mientras configuras un receptor es legítimo y no una filtración.

Un intento, sin cola

Una entrega es una única petición con cinco segundos de espera. No hay reintento ni cola: un receptor que esté caído se pierde ese evento, y el siguiente lo intenta de nuevo por su cuenta. El diálogo muestra el último estado, o el último error, de cada URL. No se siguen redirecciones, y la dirección se vuelve a comprobar como dirección pública en el momento del envío, no solo cuando se registró.

Por qué una clave de API no puede registrar uno

Los webhooks se gestionan únicamente desde una sesión iniciada. Una clave capaz de registrar uno convertiría una filtración puntual en un flujo permanente de todos los documentos posteriores, que es algo mucho peor de perder que una clave.

Relacionado: convertir documentos con una API y publicar desde GitHub Actions.