Webhook-Handler, die die Produktion überstehen
Was eine cmssy-Webhook-Zustellung ist, wie du ihre Signatur verifizierst, was die Retry-Leiter tut und wie du einen Handler schreibst, der zweimal laufen darf.
Ein Webhook-Handler sieht aus wie zehn Zeilen Code - bis er zum ersten Mal eine Zahlung doppelt bucht, während einer Secret-Rotation die Hälfte seiner Zustellungen verliert oder nach einem missglückten Deploy stillschweigend gar nichts mehr empfängt. Nichts davon ist exotisch. Genau das passiert, wenn ein Handler annimmt, dass eine Zustellung einmal ankommt, in der richtigen Reihenfolge und signiert mit dem einen Secret, das er kennt.
Dieser Beitrag ist die Checkliste für einen cmssy-Webhook-Handler, der die Produktion übersteht: was eine Zustellung ist, wie du sie verifizierst, was die Retry-Leiter tut und wie du einen Handler schreibst, der zweimal laufen darf.
Was eine Zustellung ist
Jede Zustellung ist ein POST mit einem JSON-Body:
{
"id": "0b6f2c1e-…",
"event": "order.paid",
"createdAt": "2026-10-26T14:21:19.846Z",
"data": { "workspaceId": "…", "order": { … } }
}Die id ist die Zustellungs-ID. Sie bleibt bei jedem Retry dieser Zustellung gleich und kommt außerdem im Header x-cmssy-webhook-id an, neben x-cmssy-event und x-cmssy-signature. Eine erneute Zustellung aus dem Admin oder mit redeliver_webhook_delivery erzeugt eine neue Zustellung mit neuer ID. Ein Handler, der nach der ID dedupliziert, behandelt eine manuelle Wiederholung also als neu - und genau das willst du meist, wenn du absichtlich wiederholst.
Payloads sind schlank: Content-Events tragen Referenzen, keine Inhalte, und form.submitted trägt die ID der Einsendung, nicht die Antworten. Commerce-Events tragen den Bestell-Snapshot, den deine Integration braucht - Status, Summen, steuerliche Behandlung, Käufer, Pakete und Rechnungen.
Die Signatur verifizieren

Der Header lautet t=<unix-ms>,v1=<hex>. Der Hex-Wert ist ein HMAC-SHA256 über <t>.<raw body>, mit dem Secret des Endpunkts als Schlüssel. Drei Details bringen selbstgeschriebene Verifier zu Fall:
- Eine Rotation sendet zwei Signaturen. Während ein Secret rotiert wird, trägt der Header ein
v1pro aktivem Secret. Ein Verifier, der nur das letzte prüft, lehnt jede mit dem anderen signierte Zustellung ab, solange die Rotation dauert. - Der Body muss roh sein. Parst du JSON und serialisierst es erneut, ändern sich die Bytes - Schlüsselreihenfolge, Whitespace, Unicode-Escapes. Verifiziere
await request.text()und parse erst danach. - Alte Zeitstempel müssen scheitern. Ohne Toleranzfenster lässt sich eine abgefangene Zustellung endlos wiederholen.
Der SDK-Helper erledigt alle drei: Er liest jedes v1, vergleicht in konstanter Zeit, lehnt einen Zeitstempel ab, der älter als fünf Minuten ist, und gibt das typisierte Event zurück. Er steckt in @cmssy/next, @cmssy/remix und @cmssy/astro und für alles andere in @cmssy/core.
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, process.env.CMSSY_WEBHOOK_SECRET_PREVIOUS]
.filter((s): s is string => !!s),
});
await handle(event);
return new Response(null, { status: 204 });
} catch (error) {
if (error instanceof CmssyWebhookError) {
return new Response(null, { status: 400 });
}
throw error;
}
}Die Option secret nimmt ein Array, sodass dein Handler während deines eigenen Rotationsfensters das neue und das vorherige Secret halten und Zustellungen akzeptieren kann, die mit einem der beiden signiert sind.
Die Retry-Leiter

cmssy wartet 15 Sekunden auf eine Antwort. Alles außer einem 2xx - und jeder Timeout - wird wiederholt: insgesamt acht Versuche, mit Abständen von 1 Minute, 5 Minuten, 30 Minuten, 2 Stunden, 5 Stunden, 10 Stunden und 10 Stunden. Ein Handler, der einen Nachmittag lang down ist, verliert nichts.
- Redirects sind Fehlschläge. Einem 3xx wird nicht gefolgt. Registriere die endgültige URL.
- Nur 410 Gone stoppt die Leiter. Jeder andere 4xx wird wiederholt, weil „dieser Endpunkt ist endgültig erledigt“ und „der Deploy, dem diese Route gehört, antwortet kurz mit 404“ von außen gleich aussehen. Gib 410 zurück, wenn du es so meinst.
- Retry-After wird respektiert, wenn es länger warten will, als die Leiter warten würde, gedeckelt auf 10 Stunden. Es verkürzt den Backoff nie.
- Zwanzig Fehlschläge in Folge deaktivieren den Endpunkt. Repariere den Handler und aktiviere ihn dann wieder; das setzt den Zähler zurück.
Zustellungen werden pro Endpunkt in eine Warteschlange gestellt, mit jeweils einem laufenden Versuch, sodass ein langsamer Handler seine eigenen Retries einreiht, statt von einem Fan-out überrollt zu werden.
Einen Handler schreiben, der zweimal laufen darf
At-least-once-Zustellung bedeutet, dass dein Handler früher oder später dieselbe Zustellung zweimal sieht - ein Timeout auf deiner Seite, den cmssy als Fehlschlag gezählt hat, ein Deploy, der mit 502 antwortete, nachdem die Arbeit schon erledigt war. Und weil die Zustellung nicht geordnet ist, kann ein Retry nach einem neueren Event zur selben Bestellung ankommen.
- Schnell bestätigen, danach arbeiten. Verifizieren, die Zustellung speichern, 2xx zurückgeben. Ein Handler, der vor der Antwort drei APIs aufruft, braucht irgendwann länger als 15 Sekunden und wird wiederholt, während er noch arbeitet.
- Nach der Zustellungs-ID deduplizieren. Ein Unique-Index auf
idin deiner eigenen Tabelle macht aus einem Duplikat ein No-op. - Vor Seiteneffekten den aktuellen Zustand lesen. Der Payload ist ein Snapshot vom Zeitpunkt des Events. Wenn es darauf ankommt - eine Rechnung ausstellen, eine E-Mail senden, Bestand in einem anderen System bewegen -, lies die Bestellung erneut und handle nach dem, was jetzt gilt.
- Verlass dich nie auf die Ankunftsreihenfolge. Vergleiche den Zustand im Payload oder aus dem frischen Lesen mit dem, was du bereits angewendet hast.
Wenn doch etwas schiefgeht
Jeder Versuch wird protokolliert: list_webhook_deliveries (oder Settings → Webhooks im Admin) zeigt den Status, den Antwortcode und für Teammitglieder mit webhooks:manage die ersten 8 KiB des Antwort-Bodys deines Handlers - meist genau der Stack-Trace, den du gesucht hast. Ist der Handler repariert, stelle die fehlgeschlagenen erneut zu. Eine erneute Zustellung ist eine frische Zustellung mit eigener ID; sie zählt weder zur Leiter des Originals noch zur Fehlerserie des Endpunkts.
Ein Abonnement oder viele?
Für Cache-Invalidierung abonnierst du einmal content.changed und invalidierst bei jeder Änderung; es deckt Seiten, Records, Modelle, Formulare, Medien und Einstellungen ab. Für Automatisierung abonnierst du die granularen Events, die du brauchst - order.*, shipment.*, page.published, form.submitted und den Rest. list_webhook_event_types liefert die vollständige Allowlist, gefiltert danach, was deine Rolle abonnieren darf.
Die vollständige Referenz steht in den Webhook-Docs. Ein ausgearbeitetes Beispiel für einen idempotenten Handler findest du in Rechnungsstellung für einen Headless-Shop mit Webhooks.
