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; mitenabledpausierst 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.