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; pasaenabledpara 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
- Servidor MCP: las herramientas que crean e inspeccionan endpoints.
- Vista previa de borrador: publicación, caché y qué significa una página obsoleta.
- Tokens de API: la credencial detrás de esas herramientas.