Webhook-Handler, die die Produktion überstehen
Anleitungen

Webhook-Handler, die die Produktion überstehen

Signaturen über eine Secret-Rotation hinweg, eine Retry-Leiter mit acht Versuchen und ein Handler, der zweimal laufen darf - die Checkliste für cmssy-Webhooks.

C
Cmssy Team
5 min read

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 x-cmssy-signature: t ist ein Zeitstempel in Millisekunden, v1 ist ein HMAC-SHA256 aus Zeitstempel, einem Punkt und dem rohen Body mit dem Endpunkt-Secret als Schlüssel, und während einer Secret-Rotation erscheint ein zweites v1.
Ein Zeitstempel, eine Signatur pro aktivem Secret.

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 v1 pro 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

Acht Zustellversuche: der erste, dann plus 1 Minute, 5 Minuten, 30 Minuten, 2 Stunden, 5 Stunden, 10 Stunden und 10 Stunden, insgesamt etwa 27 Stunden.
Acht Versuche in etwa 27 Stunden.

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 id in 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.