Zum Hauptinhalt springen
Ridvan Ereng
Full Stack & AI Engineer
Alle Beiträge

Webhook-Idempotenz richtig: Was Tutorials falsch machen

Stripe und n8n liefern Events doppelt. Die meisten Idempotency-Patterns aus Blog-Tutorials sind kaputt oder Overkill. Hier ist, was wirklich funktioniert.

8 min Lesezeit

Webhook-Endpoints, die State ändern, sind ein klassischer Bug-Magnet. Stripe garantiert „at-least-once delivery", n8n retried bei Timeouts, LiveKit-Events kommen aus mehreren Threads. Tutorials zeigen meist drei Sachen: Idempotency-Key in DB, Outbox-Pattern, Optimistic Locking. Zwei davon habe ich initial selbst falsch implementiert. Eine Pattern war sinnlos overkill.

Hier ist, was ich nach zwei produktiven Setups wirklich nutze, und was nicht.

Scope: Dieser Post ist über Idempotenz-Patterns, nicht über vollständige Provider-Integrationen. Eine echte Production-Stripe-Anbindung braucht mehr (siehe „Was dieser Post nicht ist" am Ende).

Der Bug, den fast alle Tutorials haben#

Das klassische Pattern aus Webhook-Tutorials sieht so aus:

async function handleStripeWebhook(req: Request) {
  const event = stripe.webhooks.constructEvent(...);
 
  const insert = await db.insert(webhookEvents)
    .values({ eventId: event.id })
    .onConflictDoNothing()
    .returning();
 
  if (insert.length === 0) {
    // already exists → skip
    return new Response(null, { status: 200 });
  }
 
  await processEvent(event);
  return new Response(null, { status: 200 });
}

Das hat einen Logik-Bug, den man erst bei einem Crash zwischen den Zeilen merkt. Wenn der Server-Prozess nach dem insert aber vor processEvent stirbt (OOM, Container-Restart, Lambda-Timeout), ist der Event-Record in der DB. Beim Provider-Retry sieht der Code „existiert schon" und skippt. Der Event wird nie verarbeitet.

Das passiert nicht oft, aber es passiert. In einem Setup hatten wir nach drei Wochen Production einen einzigen verlorenen Event, weil ein Coolify-Container-Update mitten im Webhook-Handler landete. Die Folgewirkung war eine inkonsistente DB, die zwei Wochen unentdeckt blieb.

Was wirklich funktioniert#

Idempotenz muss auf Verarbeitungs-Status basieren, nicht auf Row-Existence. Plus die Verarbeitung und das Markieren als „done" laufen in derselben Transaktion.

async function handleWebhook(req: Request) {
  const event = parseAndVerify(req);
 
  // 1. Schon erfolgreich verarbeitet? Dann fertig.
  const existing = await db.query.webhookEvents.findFirst({
    where: and(
      eq(webhookEvents.eventId, event.id),
      eq(webhookEvents.status, 'processed')
    ),
  });
  if (existing) return new Response(null, { status: 200 });
 
  // 2. Verarbeitung + Status-Markierung in einer Transaktion.
  await db.transaction(async (tx) => {
    await processEvent(event, tx);
    await tx.insert(webhookEvents)
      .values({
        eventId: event.id,
        source: event.source,
        status: 'processed',
        processedAt: new Date(),
      })
      .onConflictDoUpdate({
        target: webhookEvents.eventId,
        set: { status: 'processed', processedAt: new Date() },
      });
  });
 
  return new Response(null, { status: 200 });
}

Drei Eigenschaften. Erstens: Crash zwischen Step 1 und Step 2 → beim Retry läuft Step 1 erneut durch (kein „processed"-Record gefunden), Step 2 wird komplett neu gestartet. Zweitens: Concurrent Retries blockieren auf dem Row-Lock. Drittens: 200 auch bei „schon verarbeitet", weil der Provider sonst weiter retried.

Stripe selbst hat das im Webhooks-Best-Practice-Guide genau so dokumentiert. Tutorials draußen zeigen oft die kaputte Version mit Existence-Check.

Aber: das ist nur ein kleiner Teil von „Stripe richtig"#

Der häufigste Fehler den ich in Code-Reviews sehe ist nicht der oben gezeigte Idempotency-Bug, sondern dass Tutorials den Eindruck erwecken, mit dem Idempotency-Pattern wäre die Stripe-Integration „fertig". Ist sie nicht. Eine produktionstaugliche Stripe-Anbindung hat mindestens diese acht Bausteine.

Sync vs. Async Handling. Stripe erwartet 200 in unter 5 Sekunden. Ein Handler, der await processEvent synchron macht und der Event ein PDF generiert oder externe APIs ruft, kommt unter Last in Timeout. Real-world Pattern: Handler verifiziert Signatur, persistiert raw event, returnt 200. Ein separater Worker liest aus der DB und verarbeitet asynchron. Das macht den Idempotency-Check trivial: er ist im Worker, nicht im Handler.

Event-Type-Whitelist. Stripe hat über 200 Event-Typen. Wenn dein Webhook-Endpoint im Stripe-Dashboard auf „send all events" steht und du nur drei davon handlest, crasht jeder neue Stripe-Event-Type deinen Handler. Lösung:

const HANDLED = new Set([
  'customer.subscription.created',
  'customer.subscription.updated',
  'customer.subscription.deleted',
  'invoice.payment_succeeded',
  'invoice.payment_failed',
  'customer.subscription.trial_will_end',
]);
 
if (!HANDLED.has(event.type)) {
  // Acknowledge but don't process
  return new Response(null, { status: 200 });
}

Idempotency in der anderen Richtung. Wenn deine App Stripe ruft (Charges, Subscriptions, Refunds), brauchst du den Idempotency-Key-Header beim API-Call. Sonst kann ein Retry deiner App eine doppelte Charge auslösen.

await stripe.charges.create(
  { amount: 1000, currency: 'eur', customer: customerId },
  { idempotencyKey: `charge-${orderId}` }
);

API-Version-Pinning. Stripe hat API-Versionen, die sich teilweise inkompatibel ändern. „Latest" ist eine Wette gegen sich selbst.

const stripe = new Stripe(process.env.STRIPE_SECRET!, {
  apiVersion: '2024-11-20.acacia',
});

Test/Live-Mode-Trennung. Stripe hat zwei Webhook-Secrets pro Endpoint (eines für Test-Mode, eines für Live). Production-Code muss beide kennen oder zwei separate Endpoints haben. Tutorials erklären das selten.

Express-Body-Parser-Trap. Wenn dein Express-Setup bodyParser.json() global einsetzt, parsed der Body schon, bevor stripe.webhooks.constructEvent ihn als raw String bekommt. Signatur-Verification schlägt fehl. Lösung: bodyParser.raw({ type: 'application/json' }) nur für die Webhook-Route. In Next.js App Router ist das einfacher: req.text() statt req.json().

Dead-Letter-Queue. Nach drei fehlgeschlagenen Retries gehört der Event in eine separate Tabelle, nicht in einen Endlos-Loop. Sonst spammt Stripe und du loggst denselben Fehler tausendfach.

Subscription-Lifecycle vollständig. Für SaaS-Billing brauchst du nicht nur invoice.payment_succeeded. Mindestens dazu: die sechs aus dem Whitelist-Beispiel oben. Wenn du nur Erfolg-Events handlest, weißt du nie wann ein User wegen failed payment auf hold steht.

n8n: kein eigener Idempotency-Header nötig#

Das wäre der erste Reflex aus dem Idempotency-Pattern: gleicher Trick mit eigenem X-Idempotency-Key-Header bei n8n-Webhook-Calls. Habe ich initial gebaut. War unnötig.

Der Grund: n8n hat zwei Retry-Mechanismen, die zusammen das Problem lösen ohne zusätzliche Infrastruktur.

  1. Workflow-Settings → Retry on Failure: kann pro Workflow deaktiviert werden. Wenn dein Workflow keinen Sinn ergibt mehrfach zu laufen, schalt es ab. Dauer: zwei Klicks in der UI.
  2. Workflow-internes Pattern: ein UPSERT als ersten echten DB-Schritt. Wenn n8n den Workflow doch retried, kommt der UPSERT auf existenten Daten und ist no-op.
-- Im n8n-Postgres-Node, statt INSERT:
INSERT INTO leads (workspace_id, email, ...)
VALUES ($1, $2, ...)
ON CONFLICT (workspace_id, email) WHERE email IS NOT NULL
DO UPDATE SET enrichment_data = EXCLUDED.enrichment_data
RETURNING id;

Was ich im ersten Setup falsch gemacht habe: einen crypto.randomUUID() pro Trigger als Idempotency-Key generiert und im n8n-Workflow gegen eine webhook_events-Tabelle gecheckt. Klingt clever, ist aber sinnlos. Der Key wird pro Call neu generiert. Wenn mein Caller-Service retried, hat der zweite Call einen neuen Key, und mein Schutz greift nicht.

Lehre: wenn dein Tool eigene Retry-Semantik hat, vertraue der zuerst, bevor du eigene Layer baust.

Was Tutorials als „Lösung" verkaufen, aber Overkill ist#

Outbox-Pattern für simple Side-Effects#

Outbox sieht in jedem „Microservices-Architektur"-Buch gut aus. Bei einer Welcome-Email nach Sign-up ist es Enterprise-Architektur für ein 2-User-MVP. Realistisch:

  • Resend, SendGrid und SES haben built-in Retry-Logik. Failure-Rate für transient-errors ist unter 0,1%.
  • Bei Failure: try/catch, log mit console.error('welcome-email failed', { userId, err }), Slack-Alert wenn die Rate über 1% steigt.
  • Die Welcome-Email ist nicht Business-Critical. User kann sich auch ohne anmelden.

Outbox lohnt sich, wenn:

  • Du einen Multi-Step-Saga hast, der über drei oder mehr Services geht und jeder Schritt kompensierende Transaktionen braucht.
  • Cross-System-Replikation (z.B. CDC von Postgres nach Elasticsearch).
  • Compliance-Events (Steuer-Trail, GDPR-Audit), die garantiert irgendwann zugestellt werden müssen.

Für 95% aller SaaS-Webhook-Use-Cases ist eine async-Funktion mit retry plus monitoring genug.

Optimistic Locking für simple Concurrent-Updates#

Klassisches Beispiel: zwei LiveKit-Events kommen fast gleichzeitig rein und beide Handler schreiben in call_records. Tutorial-Lösung: Version-Spalte plus Retry-Loop. Funktioniert. Ist aber Pattern aus High-Concurrency-OLTP-Banking.

Was bei wenigen Updates pro Sekunde meist reicht: per-Field-Updates ohne Overlap.

// participant_left handler:
await db.update(callRecords)
  .set({ leftAt: event.timestamp })
  .where(eq(callRecords.id, callId));
 
// room_finished handler:
await db.update(callRecords)
  .set({ finishedAt: event.timestamp, totalDurationSec: event.duration })
  .where(eq(callRecords.id, callId));

Die beiden Handler schreiben unterschiedliche Felder. Kein Lost-Update möglich, weil Postgres die UPDATE-Befehle pro Row sequentiell ausführt. Spalten-Granularität als Schema-Design löst das, was Optimistic Locking kompliziert lösen würde.

Wenn echt mal zwei Handler dasselbe Feld schreiben müssen, ist die nächst-bessere Lösung append-only Event-Log mit „current state" als View darauf. Optimistic Locking als Hammer für jeden Nail kommt erst, wenn du echte Concurrency hast.

Eigene Idempotency-Header für interne Service-Calls#

Wenn Service A Service B ruft und beide deine sind, brauchst du keinen Idempotency-Key-Layer. Du baust den Service so, dass die Operation idempotent ist (UPSERT, MERGE, conditional INSERT). Idempotency-Header sind nur sinnvoll, wenn Caller und Callee unterschiedliche Trust-Domains haben.

Was ich tatsächlich pro Webhook-Endpoint mache#

Drei Sachen, je nach Anforderung:

  1. status='processed'-Check statt Existence-Check, immer.
  2. Verarbeitung und Status-Markierung in einer Transaktion, immer.
  3. 200-OK auch bei „already processed", immer (sonst retried der Provider).

Plus zwei Sachen je nach Provider:

  1. Webhook-Signatur verifizieren (Stripe, Twilio, GitHub haben alle ihren eigenen Mechanismus).
  2. Webhook-Event-Log mit event.id, received_at, status, payload für Retro-Debugging. Bei 1000 Events pro Tag sind das 1 MB pro Monat in der DB. Vernachlässigbar, rettet bei Bug-Investigation Stunden.

Logging-Schema:

CREATE TABLE webhook_events (
  event_id text PRIMARY KEY,
  source text NOT NULL,
  status text NOT NULL CHECK (status IN ('received', 'processed', 'failed')),
  payload jsonb NOT NULL,
  received_at timestamptz NOT NULL DEFAULT now(),
  processed_at timestamptz,
  failure_reason text
);
 
CREATE INDEX webhook_events_source_received_idx
  ON webhook_events (source, received_at DESC);

Bei einem Bug-Report drei Wochen später kann ich genau sehen, welcher Event reinkam, was ich daraus gemacht habe, und ob er erneut zugestellt wurde.

Wann was wirklich nötig ist#

SituationPattern
Provider mit at-least-once-DeliveryStatus-basierte Idempotenz, single transaction
n8n re-fired bei Workflow-RetryUPSERT in der DB-Stage des Workflows
LiveKit / Twilio / Voice-Events mit überlappenden UpdatesPer-Field-Updates, nicht-überlappende Spalten
Welcome-Email, Notification, einfache Side-Effectstry/catch + log + monitoring, fertig
Multi-Step-Saga mit KompensationOutbox + Saga-Orchestrierung
Hochfrequenter Update auf shared rowOptimistic Locking, ja

Wenn dein Use-Case in den ersten vier Zeilen ist und du Outbox oder Optimistic Locking verwendest, baust du wahrscheinlich ein Tier zu hoch.

Was dieser Post nicht ist#

Dieser Post ist eine Sammlung von Idempotenz-Patterns. Er ist explizit nicht eine vollständige Anleitung „Stripe in Production einbinden". Eine produktionstaugliche Stripe-Integration hat mindestens diese Bausteine, die ich oben angerissen habe und in einem separaten Post detailliert behandeln werde:

  • Async-Handling mit Background-Worker
  • Event-Type-Whitelist
  • Idempotency in beide Richtungen
  • API-Version-Pinning
  • Test/Live-Mode-Trennung
  • Body-Parser-Konfiguration richtig
  • Dead-Letter-Queue für failed events
  • Subscription-Lifecycle vollständig (mindestens 6 Event-Typen)
  • Customer-Portal statt eigener Cancellation-UI

Wenn du eine bestehende Webhook-Integration auditieren lassen willst, schreib mir. Bei mir gibt es einen 15-min Call. Ich schaue auf den Endpoint und die Handler-Logic, das dauert in der Regel 20 bis 30 Minuten und meistens sehe ich auf den ersten Blick, ob das Setup robust gegen Retries und Crashes ist.