Webhooks

Dos niveles de eventos, una firma HMAC sobre el cuerpo en crudo, ocho intentos con backoff, y un evento que mantiene fresco un frontend headless.

Un webhook es cómo cmssy le dice a tu app que algo cambió, en lugar de que tu app pregunte. Hay dos niveles de eventos, una forma de entrega y un detalle más importante que el resto: content.changed convierte una publicación en una página viva sobre un frontend cacheado.

Dos niveles, dos preguntas

Los niveles responden preguntas distintas, y la mayoría de integraciones solo necesita uno.

  • content.changed: "algo que sirvo cambió, invalida tu caché". Un único evento grueso. Es lo que suscribe un frontend headless.
  • Eventos de edición: "alguien hizo algo". Veintiún eventos granulares para automatización, auditoría y avisos: un mensaje de Slack al publicar una página, una ficha en el CRM al enviar un formulario.
  • order.*: diez eventos del ciclo de vida de pedidos.

list_webhook_event_types es la lista autorizada y viene filtrada por tus permisos: léela en vez de adivinar un nombre.

content.changed, para invalidar caché

Un solo evento cubre cualquier cambio en lo que sirve la API de entrega, y el payload dice qué pasó:

{
  "kind": "page",          // page | record | model | form | media | settings
  "action": "published",   // published | unpublished | created | updated | deleted
  "ids": ["6a63..."],
  "slug": "/pricing",      // solo páginas
  "modelSlug": null         // solo registros y modelos
}

Suscríbete una vez y trata cualquier par como "invalida". Ese es el sentido de lo grueso: un tipo de sujeto añadido más adelante te llega sin una suscripción nueva.

Tres bordes que conviene programar. Una operación masiva cuyo conjunto afectado es desconocido o mayor de 100 envía un array ids vacío: trátalo como "invalida todo". En un borrado por lotes de páginas, slug es la raíz del subárbol borrado; los descendientes en ids vivían bajo otros slugs. Y kind: "settings" siempre lleva ids vacío y slug nulo: nada apunta a una página concreta, así que invalida el árbol entero.

Borrar un modelo emite dos eventos: uno por la definición y otro por los registros que se fueron con ella. Un consumidor que cachea definiciones y otro que cachea registros necesitan invalidar cosas distintas.

El nivel de edición

No sustituyen a content.changed: responden la otra pregunta. Suscríbete a ellos para automatizar, no para invalidar caché.

  • Páginas: page.created, page.updated, page.deleted, page.published, page.unpublished.
  • Registros: record.created, record.updated, record.deleted.
  • Modelos: model.created, model.updated, model.deleted.
  • Formularios: form.created, form.updated, form.deleted, form.submitted.
  • Medios: media.uploaded, media.updated, media.deleted.
  • Miembros: member.added, member.updated, member.removed.
  • Ajustes: settings.updated.

Llevan la misma forma de payload que content.changed, así que un único parser sirve para ambos niveles. Los payloads son referencias ligeras, nunca contenido: form.submitted te da el id del envío y el slug del formulario, no los valores enviados: recupéralos por la API con tus propias credenciales.

Dos asimetrías son deliberadas. page.updated se dispara ante un cambio a nivel de documento, esté la página publicada o no, pero solo una página publicada emite además content.changed: editar un borrador sin publicar es trabajo de edición, no un cambio en lo que se sirve. Y media.uploaded es solo granular: un asset creado hace un segundo no puede estar referenciado por ninguna página publicada, así que no hay nada que invalidar. Editarlo o borrarlo sí emite content.changed.

Qué permisos exige una suscripción

Suscribir un endpoint a un evento exige permiso para leer lo que el evento describe, además de webhooks:manage. Una suscripción mixta los exige todos.

  • order.*: orders:view
  • page.*: pages:view
  • record.*, model.*: models:view
  • form.created|updated|deleted: forms:view
  • form.submitted: forms:submissions:view
  • media.*: media:view
  • member.*: users:view
  • settings.updated: site:config:edit

content.changed puede llevar cualquiera de los tipos entregados, así que exige la unión de lo que piden esos tipos: pages:view + models:view + forms:view + media:view. El mismo conjunto se comprueba al editar un endpoint existente: un rol que no puede suscribirse a un evento tampoco puede mantener vivo un endpoint que lo lleva.

Mantener fresco un frontend cacheado

Publicar no despliega, y tampoco esquiva tu caché. Una página con revalidate = 3600 sigue sirviendo la copia vieja hasta una hora mientras nada la invalide. Ese algo es un webhook content.changed apuntando a una ruta de revalidación:

// app/api/revalidate/route.ts
import { createCmssyRevalidateRoute } from "@cmssy/next/server";

export const POST = createCmssyRevalidateRoute({
  secret: process.env.CMSSY_WEBHOOK_SECRET,
});

npx @cmssy/cli init escribe exactamente esta ruta. Verifica la firma de la entrega y hace expirar todo lo cacheado bajo la etiqueta cmssy-content, así que el siguiente visitante renderiza el contenido publicado; pon el secreto de firma de Settings → Webhooks en CMSSY_WEBHOOK_SECRET. ¿Escribes tu propia ruta? Verifica primero el cuerpo con verifyCmssyWebhook de @cmssy/core - entonces aplican las notas de abajo.

Si tus URL llevan prefijo de idioma, revalida también las rutas localizadas: cada una se cachea por separado. Y si tu navegación, tu sitemap o los listados del padre están cacheados bajo una etiqueta, límpiala en cada evento: una página recién publicada está viva pero falta en cada menú hasta que lo hagas.

Prefiere invalidar por sujeto antes que reconstruir entero cuando puedas. subject.ids te da los registros exactos que cambiaron en escrituras unitarias, así que etiquetar tus consultas por id de registro te permite refrescar una ficha de producto en vez de todo el sitio.

Cómo es una entrega

Cada entrega es un POST con content-type: application/json y este cuerpo:

{
  "id": "6a64...",                       // el id del endpoint del webhook
  "event": "content.changed",
  "createdAt": "2026-07-25T18:30:00.000Z",
  "data": { "workspaceId": "...", "subject": { } }
}

La acompañan tres cabeceras: x-cmssy-event, x-cmssy-webhook-id y x-cmssy-signature.

Verifica la firma

La cabecera de firma es t=<unix-ms>,v1=<hex>, donde el hex es un HMAC-SHA256 sobre <t>.<cuerpo crudo> con el secreto del endpoint como clave. Durante una rotación de secreto la cabecera lleva un v1 por cada secreto activo, así que un verificador tiene que aceptar la coincidencia con cualquiera de ellos: comprobar sólo el último tira en silencio la mitad de las entregas en plena rotación.

Usa el helper que trae el SDK en lugar de escribir esto a mano. Lee cada v1, compara en tiempo constante, rechaza una marca de tiempo de más de 5 minutos - una entrega capturada no se puede reproducir - y devuelve el evento tipado. Se exporta desde @cmssy/next, @cmssy/remix y @cmssy/astro (y desde @cmssy/core si no usas ninguno de ellos):

import { verifyCmssyWebhook, CmssyWebhookError } from "@cmssy/next";

export async function POST(request: Request) {
  const body = await request.text();

  try {
    const event = await verifyCmssyWebhook({
      body,
      signatureHeader: request.headers.get("x-cmssy-signature"),
      secret: process.env.CMSSY_WEBHOOK_SECRET!,
    });

    handle(event);
    return new Response(null, { status: 204 });
  } catch (error) {
    if (error instanceof CmssyWebhookError) {
      return new Response(null, { status: 400 });
    }
    throw error;
  }
}

Pasa el cuerpo crudo: await request.text(), nunca un objeto re-serializado. Parsear y volver a serializar JSON cambia los bytes y rompe la comparación.

Reintentos

cmssy espera 5 segundos por una respuesta y no sigue redirecciones: un 3xx es un intento fallido, no un salto - registra la URL final. Todo lo que no sea 2xx, más los timeouts, se reintenta hasta 8 intentos, con esperas de 1 min, 5 min, 15 min, 30 min, 1 h, 2 h, 4 h.

Sólo 410 Gone termina la escalera antes. Cualquier otro 4xx se reintenta, porque "este endpoint se acabó para siempre" y "el despliegue que sirve esta ruta responde 404 un momento" se ven igual desde fuera. Devuelve 410 cuando lo digas en serio.

Una cabecera Retry-After - segundos o fecha HTTP - se respeta cuando pide más de lo que la escalera esperaría, con un tope de 4 horas. Nunca acorta el backoff.

Tras 20 fallos consecutivos el endpoint se desactiva automáticamente y deja de recibir entregas. Arregla el handler y vuélvelo a activar con update_webhook, lo que reinicia el contador.

Dos consecuencias para tu handler. Tiene que ser rápido: confirma con un 2xx y haz el trabajo después, o un endpoint lento se convierte en una tormenta de reintentos. Y tiene que ser idempotente: deduplica por x-cmssy-webhook-id, que es el mismo en cada intento de una entrega, igual que el createdAt del cuerpo.

list_webhook_deliveries muestra los intentos recientes con su estado y código de respuesta; las entregas se guardan 30 días.

Gestionar endpoints

  • create_webhook: devuelve el endpoint y su secreto, una sola vez. Guárdalo entonces; no vuelve a aparecer.
  • rotate_webhook_secret: devuelve un secreto nuevo, también una vez. Rotar desde el panel de administración mantiene el secreto anterior firmando en paralelo 24 horas, así que puedes desplegar sin perder entregas; rotar por MCP cambia de inmediato. En ambos casos el secreto viejo deja de verificar en cuanto termina su ventana.
  • update_webhook: actualización parcial; pasa enabled para pausar un endpoint sin borrarlo. Desactivar nunca exige los permisos de evento, así que un endpoint comprometido siempre se puede apagar.
  • list_webhooks, delete_webhook, list_webhook_deliveries.

Hasta 20 endpoints por workspace. Las URL deben ser https en producción, y los destinos privados se rechazan: localhost y sus subdominios, *.local, *.internal, literales IPv6 y los rangos IPv4 que no son la internet pública - RFC 1918, loopback, link-local, CGNAT (100.64/10), benchmarking (198.18/15) y todo a partir de 224.0.0.0. Además el nombre de host se resuelve antes de cada entrega, así que un nombre público que responda con una dirección privada también se rechaza. Un webhook que alcanza tu red interna es una superficie SSRF, no una función.

Siguientes pasos