Webhooks

Zwei Event-Ebenen, 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 zwei Event-Ebenen, eine Zustellform und ein Detail, das wichtiger ist als der Rest: content.changed macht aus einem Publish eine live Seite auf einem gecachten Frontend.

Zwei Ebenen, zwei Fragen

Die Ebenen beantworten verschiedene Fragen, und die meisten Integrationen brauchen nur eine davon.

  • content.changed - "etwas, das ich ausliefere, hat sich geändert, invalidiere deinen Cache". Ein grobes Event. Das abonniert ein headless Frontend.
  • Authoring-Events - "jemand hat etwas getan". Einundzwanzig granulare Events für Automatisierung, Audit-Trails und Benachrichtigungen: eine Slack-Nachricht beim Veröffentlichen einer Seite, ein CRM-Eintrag bei einem Formular-Versand.
  • order.* - zehn Commerce-Lifecycle-Events.

list_webhook_event_types ist die verbindliche Allowlist und wird nach deinen Berechtigungen gefiltert - lies sie, statt einen Namen zu raten.

content.changed, für Cache-Invalidierung

Ein Event deckt jede Änderung an dem ab, was die Delivery-API ausliefert, und der Payload sagt, was passiert ist:

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

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

Drei 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". Beim gestapelten Löschen von Seiten ist slug die Wurzel des gelöschten Teilbaums; die Nachfahren in ids lagen unter anderen Slugs. Und kind: "settings" trägt immer ein leeres ids und slug: null - nichts zeigt auf eine einzelne Seite, also invalidiere den ganzen Baum.

Ein Modell zu löschen sendet zwei Events: eines für die Definition und eines für die Records, die mit ihr verschwunden sind. Ein Consumer, der Definitionen cacht, und einer, der Records cacht, brauchen Unterschiedliches invalidiert.

Die Authoring-Ebene

Sie ersetzen content.changed nicht - sie beantworten die andere Frage. Abonniere sie für Automatisierung, nicht für Cache-Invalidierung.

  • Seiten - page.created, page.updated, page.deleted, page.published, page.unpublished.
  • Records - record.created, record.updated, record.deleted.
  • Modelle - model.created, model.updated, model.deleted.
  • Formulare - form.created, form.updated, form.deleted, form.submitted.
  • Medien - media.uploaded, media.updated, media.deleted.
  • Mitglieder - member.added, member.updated, member.removed.
  • Einstellungen - settings.updated.

Sie tragen dieselbe Payload-Form wie content.changed, ein Parser bedient also beide Ebenen. Payloads sind schlanke Referenzen, nie Inhalt: form.submitted liefert die Submission-ID und den Formular-Slug, nicht die gesendeten Werte - die holst du über die API mit deinen eigenen Credentials.

Zwei Asymmetrien sind Absicht. page.updated feuert bei einer Änderung auf Dokumentebene, ob die Seite veröffentlicht ist oder nicht, aber nur eine veröffentlichte Seite sendet zusätzlich content.changed - einen unveröffentlichten Entwurf zu bearbeiten ist Autorenarbeit, keine Änderung an der Auslieferung. Und media.uploaded ist nur granular: ein vor einer Sekunde erzeugtes Asset kann von keiner veröffentlichten Seite referenziert sein, es gibt also nichts zu invalidieren. Ein Asset zu bearbeiten oder zu löschen sendet sehr wohl content.changed.

Welche Berechtigungen ein Abo braucht

Einen Endpunkt auf ein Event zu abonnieren verlangt die Berechtigung, das zu lesen, was das Event beschreibt - zusätzlich zu webhooks:manage. Ein gemischtes Abo verlangt alle zusammen.

  • 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 kann jede der Delivery-Arten tragen und verlangt daher die Vereinigung dessen, was diese Arten brauchen: pages:view + models:view + forms:view + media:view. Dieselbe Menge wird beim Bearbeiten eines bestehenden Endpunkts geprüft - eine Rolle, die ein Event nicht abonnieren darf, kann einen Endpunkt, der es trägt, auch nicht am Leben halten.

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 { createCmssyRevalidateRoute } from "@cmssy/next/server";

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

npx @cmssy/cli init schreibt genau diese Route. Sie prüft die Signatur der Zustellung und lässt alles unter dem Tag cmssy-content ablaufen, sodass der nächste Besucher die veröffentlichten Inhalte rendert; das Signier-Secret aus Settings → Webhooks gehört in CMSSY_WEBHOOK_SECRET. Eigene Route? Prüfe den Body zuerst mit verifyCmssyWebhook aus @cmssy/core - dann gelten die Hinweise unten.

Tragen deine URLs ein Sprachpräfix, revalidiere auch die lokalisierten Pfade - jeder wird getrennt gecacht. Und wenn Navigation, Sitemap oder Elternlisten unter einem Tag gecacht sind, leere diesen Tag bei jedem Event: eine frisch veröffentlichte Seite ist live, fehlt aber in jeder Sidebar, bis du das tust.

Bevorzuge Invalidierung pro Subjekt statt eines vollen Rebuilds, wo es geht. subject.ids nennt dir bei Einzel-Schreibvorgängen genau die geänderten Records - taggst du deine Fetches nach Record-ID, aktualisierst du eine Produktdetailseite statt der ganzen Site.

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>, wobei der Hex-Wert ein HMAC-SHA256 über <t>.<Roh-Body> mit dem Endpunkt-Secret als Schlüssel ist. Während einer Secret-Rotation trägt der Header ein v1 pro aktivem Secret. Ein Verifier muss also einen Treffer gegen irgendeines davon akzeptieren - prüft er nur das letzte, verwirft er während der Rotation stillschweigend die Hälfte der Zustellungen.

Nimm den Helper aus dem SDK, statt das von Hand zu schreiben. Er liest jedes v1, vergleicht in konstanter Zeit, lehnt einen Zeitstempel ab, der älter als 5 Minuten ist - eine abgefangene Zustellung ist damit nicht wiederholbar - und liefert das typisierte Event. Er wird aus @cmssy/next, @cmssy/remix und @cmssy/astro exportiert (und aus @cmssy/core, falls du keines davon nutzt):

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

Übergib den rohen Body - await request.text(), niemals ein neu serialisiertes Objekt. JSON zu parsen und wieder zu stringifizieren ändert Bytes und bricht den Vergleich.

Retries

cmssy wartet 5 Sekunden auf eine Antwort und folgt keinen Redirects: Ein 3xx ist ein fehlgeschlagener Versuch, kein Hop - registriere die finale URL. Alles außer 2xx sowie Timeouts wird bis zu 8 Versuche wiederholt, mit Abständen von 1 Min, 5 Min, 15 Min, 30 Min, 1 Std, 2 Std, 4 Std.

Nur 410 Gone beendet die Leiter früher. Jedes andere 4xx wird wiederholt, denn "dieser Endpunkt ist endgültig weg" und "das Deployment hinter dieser Route antwortet kurz mit 404" sehen von außen gleich aus. Gib 410 zurück, wenn du es so meinst.

Ein Retry-After-Header - Sekunden oder HTTP-Datum - wird beachtet, wenn er länger verlangt als die Leiter ohnehin wartet, gedeckelt auf 4 Stunden. Er verkürzt den Backoff nie.

Nach 20 aufeinanderfolgenden Fehlern wird ein Endpunkt automatisch deaktiviert und erhält keine Zustellungen mehr. Repariere den Handler und aktiviere ihn dann per update_webhook wieder - das setzt den Zähler zurück.

Zwei Konsequenzen für deinen Handler. Er muss schnell sein: mit 2xx bestätigen und die Arbeit danach erledigen, sonst wird ein langsamer Endpunkt zum Retry-Sturm. Und er muss idempotent sein: dedupliziere über x-cmssy-webhook-id, das bei jedem Versuch derselben Zustellung gleich bleibt - genau wie createdAt im Body.

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. Eine Rotation über die Admin-UI lässt das vorherige Secret 24 Stunden parallel weitersignieren, du kannst also deployen, ohne eine Zustellung zu verlieren; über MCP schaltet die Rotation sofort um. So oder so verifiziert das alte Secret ab dem Ende seines Fensters nicht mehr.
  • update_webhook - Teil-Update; mit enabled pausierst du einen Endpunkt, ohne ihn zu löschen. Deaktivieren verlangt nie die Event-Berechtigungen, ein kompromittierter Endpunkt lässt sich also immer abschalten.
  • list_webhooks, delete_webhook, list_webhook_deliveries.

Bis zu 20 Endpunkte pro Workspace. URLs müssen in Produktion https sein, und private Ziele werden abgelehnt: localhost samt Subdomains, *.local, *.internal, IPv6-Literale sowie die IPv4-Bereiche, die nicht das öffentliche Internet sind - RFC 1918, Loopback, Link-Local, CGNAT (100.64/10), Benchmarking (198.18/15) und alles ab 224.0.0.0. Der Hostname wird zudem vor jeder Zustellung aufgelöst, ein öffentlicher Name mit privater Antwortadresse wird also ebenfalls abgelehnt. 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.