Jetzt mit KI-gestütztem Page Building über den MCP-Server

Webhooks

Elf Events, eine HMAC-Signatur über den rohen Body, acht Versuche mit Backoff - und ein Event, das ein headless Frontend frisch hält.

Ein Webhook ist, wie cmssy deiner App sagt, dass sich etwas geändert hat - statt dass deine App fragt. Es gibt elf Events, eine Zustellform und ein Detail, das wichtiger ist als der Rest: content.changed macht aus einem Publish eine live Seite auf einem gecachten Frontend.

Die Events

list_webhook_event_types ist die verbindliche Allowlist - lies sie, statt einen Namen zu raten. Heute sind es elf:

  • Bestellungen - order.created, order.partially_paid, order.paid, order.partially_refunded, order.refunded, order.canceled, order.fulfilled, order.returned, order.edited, order.pipeline_changed.
  • Inhalt - content.changed, ein grobes Event für Seiten, Records, Formulare und Workspace-Einstellungen.

content.changed ist bewusst grob

Es gibt kein page.published. Ein Event deckt jede Inhaltsänderung ab, und der Payload sagt, was passiert ist:

{
  "kind": "page",          // page | record | form | settings
  "action": "published",   // published | unpublished | created | updated | deleted
  "ids": ["6a63..."],
  "slug": "/pricing",      // nur Seiten
  "modelSlug": null         // nur Records
}

Einmal abonnieren und jedes Paar als "invalidieren" behandeln. Genau darum ist es grob: eine später ergänzte Subjektart erreicht dich ohne neues Abo.

Einstellungen sind so eine Art. Ändern sich Branding oder die aktiven Sprachen, kommt kind: "settings" mit action: "updated", einem leeren ids-Array und slug: null - nichts zeigt auf eine einzelne Seite, also invalidiere den ganzen Baum. Ohne das wartet ein neues Logo oder eine frisch aktivierte Sprache dein Cache-Fenster ab.

Zwei Kanten gehören eingeplant. Eine Massenoperation, deren betroffene Menge unbekannt oder größer als 100 ist, sendet ein leeres ids-Array - behandle das als "alles invalidieren". Und beim gestapelten Löschen von Seiten ist slug die Wurzel des gelöschten Teilbaums; die Nachfahren in ids lagen unter anderen Slugs.

Ein gecachtes Frontend frisch halten

Veröffentlichen ist kein Deploy - und umgeht auch deinen Cache nicht. Eine Seite mit revalidate = 3600 liefert bis zu einer Stunde die alte Kopie, solange nichts sie invalidiert. Dieses Etwas ist ein content.changed-Webhook auf eine Revalidierungsroute:

// 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;

  // Kein Slug heißt: betroffene Menge unbekannt - den ganzen Baum invalidieren.
  if (slug) revalidatePath(slug);
  else revalidatePath("/", "layout");

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

Tragen deine URLs ein Sprachpräfix, revalidiere auch die lokalisierten Pfade - jeder wird getrennt gecacht.

Wie eine Zustellung aussieht

Jede Zustellung ist ein POST mit content-type: application/json und diesem Body:

{
  "id": "6a64...",                       // die ID des Webhook-Endpunkts
  "event": "content.changed",
  "createdAt": "2026-07-25T18:30:00.000Z",
  "data": { "workspaceId": "...", "subject": { } }
}

Dazu kommen drei Header: x-cmssy-event, x-cmssy-webhook-id und x-cmssy-signature.

Signatur prüfen

Der Signatur-Header lautet t=<unix-ms>,v1=<hex>; der Hex-Wert ist ein HMAC-SHA256 über <t>.<roher Body> mit dem Endpunkt-Secret als Schlüssel. Signiere den rohen Body - geparstes JSON neu zu serialisieren ändert Bytes und bricht den Vergleich:

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);
  // Auch alte Timestamps ablehnen: eine mitgeschnittene Zustellung ist replaybar.
  return a.length === b.length && timingSafeEqual(a, b);
}

Retries

cmssy wartet 5 Sekunden auf eine Antwort. Alles außer 2xx - oder ein Timeout - wird wiederholt, bis zu 8 Versuche, mit Backoff 1 Min, 5 Min, 15 Min, 30 Min, 1 h, 2 h, 4 h. Ein permanenter Fehler bricht früher ab.

Zwei Konsequenzen für deinen Handler. Er muss schnell sein: mit 2xx bestätigen und die Arbeit danach tun, sonst wird ein langsamer Endpunkt zum Retry-Sturm. Und er muss idempotent sein: eine bereits verarbeitete Zustellung kann erneut kommen, mit derselben id und createdAt.

list_webhook_deliveries zeigt die letzten Versuche mit Status und Response-Code; Zustellungen werden 30 Tage aufbewahrt.

Endpunkte verwalten

  • create_webhook - liefert den Endpunkt und sein Secret, einmalig. Speichere es sofort; es kommt nie wieder.
  • rotate_webhook_secret - liefert ein neues Secret, ebenfalls einmalig. Alte Signaturen verifizieren sofort nicht mehr.
  • update_webhook - Teil-Update; mit enabled pausierst du einen Endpunkt, ohne ihn zu löschen.
  • list_webhooks, delete_webhook, list_webhook_deliveries.

Bis zu 20 Endpunkte pro Workspace. URLs müssen in Produktion https sein, private Ziele werden abgelehnt: localhost, *.local, *.internal, Link-Local und RFC-1918-Bereiche. Ein Webhook, der dein internes Netz erreicht, ist eine SSRF-Fläche, kein Feature.

Nächste Schritte

  • MCP-Server - die Tools, die Endpunkte anlegen und prüfen.
  • Entwurfsvorschau - Veröffentlichen, Caching und was eine veraltete Seite bedeutet.
  • API-Tokens - die Credentials hinter diesen Tools.