Alle Artikel
webhook designidempotencyretriesdistributed systems

Webhook-Retries und Idempotenz ohne Tränen

Jeder große Webhook-Provider – Stripe, GitHub, Shopify, Twilio, Slack – liefert dasselbe Event absichtlich mehr als einmal. Man nennt das at-least-once delivery. Wenn dein Handler nicht dafür ausgelegt ist, bekommst du doppelt belastete Kunden, doppelte GitHub-PR-Labels und zweimal ausgelöste Slack-Benachrichtigungen. Der Fix ist klein und unterschätzt: gestalte jeden Handler so, dass er bei wiederholter Zustellung sicher ist.

Warum Duplikate entstehen

Provider wiederholen, wenn sie nicht rechtzeitig ein sauberes 2xx bekommen. Das Retry-Fenster variiert – Stripe: bis zu 3 Tage mit exponentiellem Backoff, GitHub: 8 Versuche über ~8 Stunden, Shopify: 48 Stunden. Meistens war die ursprüngliche Zustellung erfolgreich; das Netzwerk hat nur die Antwort auf dem Rückweg verloren. Der Provider weiß das nicht. Also wiederholt er. Also läuft dein Handler zweimal.

Das ist kein zu behebender Bug; es ist eine Eigenschaft des Systems. Baue dafür.

Das Idempotenz-Muster, das tatsächlich funktioniert

Jedes Event eines großen Providers enthält eine eindeutige Event-ID. Stripe: evt_xxx. GitHub: X-GitHub-Delivery-Header (eine UUID). Shopify: X-Shopify-Webhook-Id. Speichere die ID beim ersten Verarbeiten des Events. Beim Retry schlägst du sie nach – wenn du sie schon gesehen hast, gib 200 zurück, ohne den Handler erneut auszuführen.

async function handleWebhook(event) {
  const eventId = event.id;

  // Atomic insert-if-not-exists
  const inserted = await db.processedEvents.insertIfNew({
    eventId,
    receivedAt: new Date(),
  });

  if (!inserted) {
    // We've handled this one. Be polite about it.
    return { status: 200, body: 'already processed' };
  }

  // First time we've seen it. Do the work.
  await actuallyHandleEvent(event);

  return { status: 200 };
}

Die entscheidende Form: der Insert und die Handler-Logik müssen atomar zusammen sein, oder der Insert muss zuerst kommen. Wenn du zuerst handleEvent aufrufst und danach die ID einfügst, hinterlässt ein Crash mitten im Handler keinen Eintrag, und der Retry verarbeitet erneut.

Wo der Dedup-Key gespeichert wird

  • Dieselbe DB-Transaktion wie der Business-Write. Am besten. Dedup-Key und Seiteneffekt committen entweder beide oder keiner. Nutze einen Unique-Constraint auf der Event-ID-Spalte.
  • Redis mit langem TTL. Okay für unkritische Events. Passe die TTL an das längste Retry-Fenster jedes Providers an, mit dem du integrierst – beginne bei 7 Tagen.
  • In-Memory-Cache. Nein. Dein Prozess startet neu, der Cache ist weg, und der nächste Retry schafft es, einen bereits gelaufenen Handler erneut auszuführen.

Wie „nicht idempotent“ in der Praxis aussieht

Ein echtes Beispiel, das uns passiert ist: ein Stripe-invoice.payment_succeeded-Handler, der account.credit(amount) direkt aufrief. Jede erfolgreiche Zahlung erhöhte ein Guthaben. Meistens genau einmal. Gelegentlich zweimal, wenn ein Retry dasselbe Event erwischte. Wir fanden es, weil ein Kunde höflich per E-Mail fragte, warum seine Rechnung über 50 $ seinem Konto 100 $ gutgeschrieben hatte.

Der Fix war nicht, Stripes Retry-Policy zu ändern oder gegen den Provider zu kämpfen. Es war ein Unique-Index auf events_processed.stripe_event_id und ein Guard am Anfang des Handlers. Der ganze Patch waren elf Zeilen.

Auch das Handler-Timing zählt

Provider haben harte Timeouts. GitHub: 10 Sekunden. Discord-Interaction-Endpunkte: 3 Sekunden. Stripe und Shopify sind nachsichtiger (~30 Sekunden), aber du bekommst trotzdem Retries, wenn du langsam bist. Das Muster ist immer gleich: schnell antworten, Arbeit asynchron erledigen.

async function handleWebhook(event) {
  if (await alreadyProcessed(event.id)) {
    return { status: 200 };
  }

  // Don't await the heavy work — queue it.
  await queue.enqueue('process-stripe-event', event);

  // 200 immediately. Worker picks up the event.
  return { status: 200 };
}

Der Queue-Worker erledigt das Langsame. Der Webhook-Handler ist im Grunde ein Deduper + ein Queue-Producer. Dieses Muster bleibt unter Retries korrekt, weil der Queue-Producer selbst idempotent ist (Insert-if-new auf der Event-ID), und jedes Doppel-Enqueue vom selben Guard im Worker abgefangen wird.

Teste es bitte lokal

Idempotenz zu testen ist genau das, wofür Tunnel-Replay gemacht wurde. Erfasse eine Event-Zustellung im Anfrageverlauf deines Tunnels und spiele sie zehnmal gegen deinen lokalen Handler ab. Der erste Aufruf sollte in deine Datenbank schreiben. Die nächsten neun sollten 200 zurückgeben, ohne sie zu berühren. Beobachte deine DB-Logs zur Bestätigung.

Das ist der Test, den die meisten Teams überspringen und den die meisten Teams nicht überspringen sollten.

Providerspezifische Retry-Policies

  • Stripe: wiederholt 3 Tage, exponentielles Backoff. Bricht nach 16 Versuchen ab.
  • GitHub: 8 Versuche, abgeschlossen in ~8 Stunden. Danach wird die Zustellung als fehlgeschlagen markiert.
  • Shopify: 48 Stunden, exponentielles Backoff. Deaktiviert das Webhook-Abo nach 19 aufeinanderfolgenden Fehlern über 48 Stunden.
  • Twilio: standardmäßig ein Retry, konfigurierbar auf bis zu 3.
  • Slack: 3 Retries mit Backoff (1 s, 30 s, 5 min).

Setze deine Dedup-TTL auf das längste Fenster, das dich interessiert, plus einen Puffer.

Das Fazit

Doppelte Zustellung ist ein Vertrag, kein Defekt. Wenn du einen Webhook-Handler ausgeliefert hast, der increment, send_email oder charge_card ohne Dedup-Key aufruft, hast du einen Bug ausgeliefert. Ihn zu patchen ist klein. Ihn nach einer Kundenbeschwerde zu finden ist teuer.

Mehr zum End-to-End-Testen davon im Replay-Debugging-Leitfaden. Signaturprüfung liegt davor – das behandelt der Signatur-Leitfaden. Tritt der PortPreview-Warteliste bei für Tunnel + Replay rund um genau diesen Workflow.

Häufig gestellte Fragen

Warum senden Webhook-Provider dasselbe Event zweimal?
Provider nutzen at-least-once delivery. Wenn sie nicht rechtzeitig eine 2xx-Antwort erhalten, wiederholen sie – selbst wenn der ursprüngliche Handler tatsächlich erfolgreich war, aber die Antwort verloren ging. Behandle doppelte Zustellung als garantiertes Feature des Systems, nicht als Sonderfall.
Wie mache ich einen Webhook-Handler idempotent?
Nutze die vom Provider gesendete Event-ID (Stripe evt_xxx, GitHubs X-GitHub-Delivery-Header, Shopifys X-Shopify-Webhook-Id) als Dedup-Key. Füge sie mit einem Unique-Constraint am Anfang deines Handlers ein; schlägt der Insert fehl, weil sie schon existiert, gib 200 zurück, ohne die Business-Logik erneut auszuführen.
Wie lange sollte ich den Dedup-Key behalten?
Mindestens so lange wie das längste Retry-Fenster des Providers. Stripe wiederholt 3 Tage, Shopify 48 Stunden. Eine 7-Tage-TTL deckt alle großen Provider mit Reserve ab. Wenn du Speicher dafür hast, behalte ihn für immer – er ist auch fürs Auditing nützlich.