Ahora con creación de páginas por IA vía el servidor MCP

Webhooks

Once 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 once 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.

Los eventos

list_webhook_event_types es la lista autorizada: léela en vez de adivinar un nombre. Hoy devuelve once:

  • Pedidos: order.created, order.partially_paid, order.paid, order.partially_refunded, order.refunded, order.canceled, order.fulfilled, order.returned, order.edited, order.pipeline_changed.
  • Contenido: content.changed, un único evento grueso para páginas, registros, formularios y ajustes del workspace.

content.changed es grueso a propósito

No existe page.published. Un solo evento cubre cualquier mutación de contenido, y el payload dice qué pasó:

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

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.

Los ajustes son uno de esos tipos. Cambiar la marca o los idiomas activos emite kind: "settings" con action: "updated", un array ids vacío y slug nulo: nada apunta a una página concreta, así que invalida el árbol entero. Sin eso, un logo nuevo o un idioma recién activado espera a que caduque tu caché.

Dos 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". Y 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.

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 { revalidatePath } from "next/cache";
import { NextResponse, type NextRequest } from "next/server";

export async function POST(request: NextRequest) {
  const secret = process.env.CMSSY_REVALIDATE_SECRET;
  const provided =
    request.nextUrl.searchParams.get("secret") ??
    request.headers.get("x-revalidate-secret");
  if (!secret || provided !== secret) {
    return NextResponse.json({ revalidated: false }, { status: 401 });
  }

  const { data } = (await request.json().catch(() => ({}))) as {
    data?: { subject?: { slug?: string | null } };
  };
  const slug = data?.subject?.slug;

  // Sin slug el conjunto es desconocido: invalida el árbol entero.
  if (slug) revalidatePath(slug);
  else revalidatePath("/", "layout");

  return NextResponse.json({ revalidated: true, path: slug ?? "/" });
}

Si tus URL llevan prefijo de idioma, revalida también las rutas localizadas: cada una se cachea por separado.

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 en crudo> con el secreto del endpoint como clave. Firma el cuerpo en crudo: reserializar el JSON parseado cambia bytes y rompe la comparación.

import { createHmac, timingSafeEqual } from "crypto";

export function verifyCmssySignature(
  header: string | null,
  rawBody: string,
  secret: string,
): boolean {
  const parts = Object.fromEntries(
    (header ?? "").split(",").map((p) => p.split("=") as [string, string]),
  );
  if (!parts.t || !parts.v1) return false;

  const expected = createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1);
  // Rechaza también una marca de tiempo vieja: una entrega capturada es reproducible.
  return a.length === b.length && timingSafeEqual(a, b);
}

Reintentos

cmssy espera 5 segundos una respuesta. Cualquier cosa que no sea 2xx -o un timeout- se reintenta, hasta 8 intentos, con backoff de 1 min, 5 min, 15 min, 30 min, 1 h, 2 h, 4 h. Un fallo permanente corta antes.

Dos consecuencias para tu handler. Debe ser rápido: responde 2xx y haz el trabajo después, o un endpoint lento se convierte en una tormenta de reintentos. Y debe ser idempotente: una entrega ya procesada puede volver, con el mismo id y createdAt.

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. Las firmas viejas dejan de verificar de inmediato.
  • update_webhook: actualización parcial; pasa enabled para pausar un endpoint sin borrarlo.
  • 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, *.local, *.internal, link-local y rangos RFC 1918. Un webhook capaz de alcanzar tu red interna es una superficie SSRF, no una función.

Siguientes pasos