Stripe Webhooks mit Claude Code: Sichere Zahlungs-Events 2026

Webhooks sind das Rückgrat jeder Stripe-Integration — aber falsch implementiert führen sie zu doppelten Buchungen, verpassten Zahlungen und inkonsistenten Subscription-States. Claude Code kennt alle Fallstricke und implementiert robuste Webhook-Handler mit Idempotenz, Signature-Verifikation und Dunning-Management.

Warum Webhooks mehr sind als ein HTTP-Endpoint

Viele Entwickler bauen ihren Stripe-Webhook als einfachen POST-Endpoint: Event reinkommen, Datenbank updaten, fertig. Das klingt simpel — bis Stripe dasselbe Event zweimal schickt (was es tut), der Server während der Verarbeitung crasht, oder eine Subscription gekündigt wird während gerade eine Invoice verarbeitet wird. Claude Code implementiert Webhooks nach Production-Standard: Signature-Verifikation, Idempotenz über event.id, und eine Event-Queue für zuverlässige Verarbeitung.

Stripe Webhook-Garantie: Stripe garantiert at-least-once delivery — dasselbe Event kann mehrfach ankommen. Idempotenz ist keine Option, sondern Pflicht. Claude Code implementiert das automatisch wenn du es darauf hinweist.

1. Webhook-Architektur: Verifikation, Idempotenz und Event-Queue

1

Signature prüfen

stripe.webhooks.constructEvent() mit Webhook-Secret — verhindert gefälschte Events

2

Idempotenz prüfen

event.id in processed_events speichern — verhindert Doppelverarbeitung

3

Sofort 200 zurück

Stripe wartet max. 30s — schwere Arbeit in Queue auslagern

4

Async verarbeiten

Worker holt Events aus Queue, verarbeitet, markiert als done

CORE Webhook-Endpoint mit Signature-Verifikation

// Prompt für Claude Code: // "Implementiere Stripe Webhook Handler mit: // - stripe.webhooks.constructEvent() Signature-Verifikation // - Idempotenz via event.id in PostgreSQL processed_events Tabelle // - Sofort 200 antworten, dann Event in BullMQ Queue pushen // - Fehler-Logging aber KEIN 500 zurückgeben (sonst Stripe Retry-Storm)" import Stripe from 'stripe'; import { Queue } from 'bullmq'; import { db } from './db'; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); const webhookQueue = new Queue('stripe-events', { connection: { host: process.env.REDIS_HOST, port: 6379 } }); // WICHTIG: raw body nötig für Signatur-Verifikation! // Express: app.use('/webhook', express.raw({ type: 'application/json' })) export async function handleStripeWebhook(req, res) { const sig = req.headers['stripe-signature']; let event: Stripe.Event; try { // constructEvent wirft wenn Signatur ungültig — NIEMALS skippen! event = stripe.webhooks.constructEvent( req.body, // raw Buffer, kein JSON.parse! sig, process.env.STRIPE_WEBHOOK_SECRET! ); } catch (err) { console.error(`Webhook signature verification failed: ${err.message}`); return res.status(400).send(`Webhook Error: ${err.message}`); } // Idempotenz: Event bereits verarbeitet? const existing = await db.query( 'SELECT id FROM processed_events WHERE stripe_event_id = $1', [event.id] ); if (existing.rows.length > 0) { console.log(`Event ${event.id} already processed, skipping`); return res.json({ received: true, duplicate: true }); } // Event in Queue pushen — sofort 200 zurückgeben await webhookQueue.add( event.type, { event, eventId: event.id }, { attempts: 3, backoff: { type: 'exponential', delay: 5000 }, removeOnComplete: true } ); // Als "in Verarbeitung" markieren (verhindert Race Conditions) await db.query( 'INSERT INTO processed_events (stripe_event_id, event_type, received_at) VALUES ($1, $2, NOW())', [event.id, event.type] ); // Stripe will 200 innerhalb 30s — immer sofort antworten! res.json({ received: true }); }

CORE BullMQ Worker für asynchrone Event-Verarbeitung

// Prompt: // "Erstelle BullMQ Worker der Stripe Events verarbeitet. // Switch auf event.type, separater Handler pro Event-Typ. // Bei Fehler: Event als 'failed' in DB markieren, nicht still schlucken." import { Worker, Job } from 'bullmq'; const worker = new Worker( 'stripe-events', async (job: Job) => { const { event } = job.data; console.log(`Processing Stripe event: ${event.type} (${event.id})`); switch (event.type) { case 'customer.subscription.created': await handleSubscriptionCreated(event.data.object); break; case 'customer.subscription.updated': await handleSubscriptionUpdated(event.data.object); break; case 'customer.subscription.deleted': await handleSubscriptionDeleted(event.data.object); break; case 'invoice.paid': await handleInvoicePaid(event.data.object); break; case 'invoice.payment_failed': await handlePaymentFailed(event.data.object); break; case 'checkout.session.completed': await handleCheckoutCompleted(event.data.object); break; default: console.log(`Unhandled event type: ${event.type}`); } // Als erfolgreich verarbeitet markieren await db.query( 'UPDATE processed_events SET processed_at = NOW(), status = $1 WHERE stripe_event_id = $2', ['done', event.id] ); }, { connection: { host: process.env.REDIS_HOST } } ); worker.on('failed', (job, err) => { console.error(`Event ${job?.data.eventId} failed after ${job?.attemptsMade} attempts:`, err`); // Alerting: PagerDuty / Telegram / Slack });
Raw Body ist Pflicht: Express parst JSON standardmäßig — aber Stripe benötigt den rohen Request-Body für die HMAC-Signatur. Setze express.raw({ type: 'application/json' }) NUR für den Webhook-Pfad, nicht global. Claude Code erkennt diesen Fehler sofort.

2. Subscription-Events: created, updated, deleted und checkout.session.completed

Subscription-Events bilden den Kern jeder SaaS-Integration. Der häufigste Fehler: den Subscription-Status direkt aus dem Event lesen statt ihn mit der Stripe API zu verifizieren. Claude Code kennt die korrekte Reihenfolge.

Event Wann Was tun
customer.subscription.created Neue Subscription gestartet User-Plan in DB setzen, Willkommens-Email senden
customer.subscription.updated Plan-Wechsel, Pause, Trial-Ende Plan + Status in DB updaten, Features aktivieren/deaktivieren
customer.subscription.deleted Kündigung wirksam Access revoken, Offboarding-Flow starten
checkout.session.completed Checkout abgeschlossen Customer-ID mit User verknüpfen, Onboarding starten

SUB Subscription-Handler: created + updated

// Prompt: // "Implementiere Subscription Created/Updated Handler. // Status-Mapping: active→Pro, trialing→Trial, past_due→GracePeriod, canceled→Free // Plan-ID aus subscription.items.data[0].price.id lesen // Immer DB-Transaktion nutzen — kein Partial-Update" async function handleSubscriptionCreated(subscription: Stripe.Subscription) { const customerId = subscription.customer as string; const priceId = subscription.items.data[0]?.price.id; const status = mapSubscriptionStatus(subscription.status); const client = await db.pool.connect(); try { await client.query('BEGIN'); // Customer-ID → User-ID auflösen const userResult = await client.query( 'SELECT id FROM users WHERE stripe_customer_id = $1', [customerId] ); if (!userResult.rows[0]) throw new Error(`No user for customer ${customerId}`); const userId = userResult.rows[0].id; await client.query( `INSERT INTO subscriptions (user_id, stripe_subscription_id, stripe_price_id, status, current_period_start, current_period_end, trial_end) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (stripe_subscription_id) DO UPDATE SET status = EXCLUDED.status, stripe_price_id = EXCLUDED.stripe_price_id, current_period_end = EXCLUDED.current_period_end`, [ userId, subscription.id, priceId, status, new Date(subscription.current_period_start * 1000), new Date(subscription.current_period_end * 1000), subscription.trial_end ? new Date(subscription.trial_end * 1000) : null ] ); // Feature-Access basierend auf Plan setzen await updateUserFeatures(client, userId, priceId, status); await client.query('COMMIT'); // Side effects NACH dem Commit if (subscription.status === 'active') { await emailService.sendWelcomePro(userId); } } catch (err) { await client.query('ROLLBACK'); throw err; } finally { client.release(); } } function mapSubscriptionStatus(stripeStatus: string): string { const map: Record<string, string> = { 'active': 'pro', 'trialing': 'trial', 'past_due': 'grace_period', 'canceled': 'free', 'unpaid': 'suspended', 'incomplete': 'pending', }; return map[stripeStatus] ?? 'unknown'; }

CHECKOUT checkout.session.completed Handler

// Prompt: // "checkout.session.completed Handler: // - client_reference_id enthält unsere interne User-ID // - customer_id in users Tabelle speichern // - mode='subscription' → Subscription verknüpfen // - mode='payment' → One-Time Purchase aktivieren" async function handleCheckoutCompleted(session: Stripe.Checkout.Session) { const userId = session.client_reference_id; // Immer mitgeben beim Checkout! const customerId = session.customer as string; if (!userId) { console.error('checkout.session.completed: missing client_reference_id!'); return; } // Customer-ID mit User verknüpfen (persistent für alle künftigen Events) await db.query( 'UPDATE users SET stripe_customer_id = $1 WHERE id = $2', [customerId, userId] ); if (session.mode === 'subscription') { // Subscription-Details kommen via customer.subscription.created Event // Hier nur Onboarding starten await onboardingService.start(userId, { source: 'checkout' }); } else if (session.mode === 'payment') { // One-Time Purchase: direkt aktivieren const lineItems = await stripe.checkout.sessions.listLineItems(session.id); for (const item of lineItems.data) { await purchaseService.activate(userId, item.price!.product as string); } } }

3. Invoice Events: invoice.paid, invoice.payment_failed und Dunning Management

Invoice Events steuern den Zahlungsfluss — und das Dunning Management (Mahnwesen) für fehlgeschlagene Zahlungen. Claude Code implementiert einen mehrstufigen Dunning-Prozess der Churn reduziert ohne Kunden zu verärgern.

invoice.paid ✓
payment_failed (1. Versuch)
payment_failed (2. Versuch, +3 Tage)
payment_failed (3. Versuch, +7 Tage)
subscription → past_due → canceled

INVOICE invoice.paid und invoice.payment_failed

// Prompt: // "invoice.paid: Zahlung als erfolgt markieren, Rechnung als PDF speichern URL aus hosted_invoice_url // invoice.payment_failed: Dunning-Stage tracken (1→2→3 Versuche), // Stage 1: sanfte Email, Stage 2: Warnung + Support anbieten, Stage 3: Suspension ankündigen" async function handleInvoicePaid(invoice: Stripe.Invoice) { const subscriptionId = invoice.subscription as string; await db.query( `INSERT INTO invoices (stripe_invoice_id, subscription_id, amount_paid, currency, period_start, period_end, invoice_pdf, hosted_url, paid_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW()) ON CONFLICT (stripe_invoice_id) DO UPDATE SET paid_at = NOW()`, [ invoice.id, subscriptionId, invoice.amount_paid, invoice.currency, new Date(invoice.period_start * 1000), new Date(invoice.period_end * 1000), invoice.invoice_pdf, invoice.hosted_invoice_url ] ); // Subscription-Status zurück auf 'active' setzen (war ggf. 'grace_period') await db.query( 'UPDATE subscriptions SET status = $1, payment_failed_count = 0 WHERE stripe_subscription_id = $2', ['pro', subscriptionId] ); // Rechnung per Email versenden const user = await getUserBySubscription(subscriptionId); await emailService.sendInvoiceReceipt(user, invoice.hosted_invoice_url!); } async function handlePaymentFailed(invoice: Stripe.Invoice) { const subscriptionId = invoice.subscription as string; // Attempt-Count erhöhen const result = await db.query( `UPDATE subscriptions SET payment_failed_count = payment_failed_count + 1, last_payment_failed_at = NOW() WHERE stripe_subscription_id = $1 RETURNING payment_failed_count, user_id`, [subscriptionId] ); const { payment_failed_count, user_id } = result.rows[0]; const user = await getUserById(user_id); // Dunning-Stage bestimmen und handeln switch (payment_failed_count) { case 1: // Erster Fehlschlag: sanfte Erinnerung await emailService.sendPaymentFailed_Gentle(user, invoice.hosted_invoice_url!); break; case 2: // Zweiter Fehlschlag: Warnung + Support anbieten await emailService.sendPaymentFailed_Warning(user); await crmService.createSupportTask(user_id, 'payment_failed_2nd'); break; case 3: // Dritter Fehlschlag: Suspension ankündigen await emailService.sendPaymentFailed_Final(user); // Stripe kündigt automatisch nach Retry-Periode (konfigurierbar im Dashboard) break; default: // Mehr als 3: Stripe hat bereits gecancelt, subscription.deleted kommt console.log(`Payment failed ${payment_failed_count} times for subscription ${subscriptionId}`); } }
Dunning-Konfiguration im Stripe Dashboard: Unter Billing → Subscriptions → Retry schedule kannst du festlegen, wie oft Stripe automatisch retried (empfohlen: 3-4 Versuche über 14 Tage) und ob nach dem letzten Fehlschlag automatisch gekündigt wird. Claude Code konfiguriert das per API wenn du es bittest.

4. Customer Portal: Billing Portal, Plan-Upgrades und Kündigung

Das Stripe Customer Portal ist No-Code Self-Service für deine Kunden — sie können Zahlungsmethoden updaten, Plan wechseln, Kündigen und Rechnungen herunterladen. Claude Code integriert es in Minuten und reagiert korrekt auf die resultierenden Webhook-Events.

PORTAL Customer Portal Session erstellen und konfigurieren

// Prompt: // "Stripe Customer Portal integrieren: // - Portal-Session für eingeloggten User erstellen // - return_url nach Portal-Nutzung // - Portal-Konfiguration via API: Subscriptions erlauben, Cancellations erlauben, // Plan-Wechsel zu Pro/Enterprise erlauben, Payment-Update erlauben" // Einmalig: Portal-Konfiguration erstellen (oder im Dashboard) async function createPortalConfiguration() { const config = await stripe.billingPortal.configurations.create({ business_profile: { headline: 'Manage your subscription', privacy_policy_url: 'https://agentic-movers.com/privacy', terms_of_service_url: 'https://agentic-movers.com/terms', }, features: { subscription_cancel: { enabled: true, mode: 'at_period_end', // nicht sofort — Nutzer bleibt bis Periodenende cancellation_reason: { enabled: true, options: ['too_expensive', 'missing_features', 'switched_service', 'unused', 'other'] } }, subscription_update: { enabled: true, default_allowed_updates: ['price'], // Plan-Wechsel erlauben proration_behavior: 'create_prorations', products: [ { product: process.env.STRIPE_PRODUCT_ID_PRO!, prices: [process.env.STRIPE_PRICE_PRO_MONTHLY!, process.env.STRIPE_PRICE_PRO_YEARLY!] } ] }, payment_method_update: { enabled: true }, invoice_history: { enabled: true }, } }); return config.id; } // Route: GET /billing/portal → Redirect zum Stripe Portal export async function redirectToPortal(req, res) { const user = req.user; // Auth-Middleware hat User gesetzt if (!user.stripe_customer_id) { return res.status(400).json({ error: 'No active subscription' }); } const session = await stripe.billingPortal.sessions.create({ customer: user.stripe_customer_id, return_url: `${process.env.APP_URL}/dashboard?portal=returned`, configuration: process.env.STRIPE_PORTAL_CONFIG_ID, }); // Kurzer Redirect — URL ist ~60 Minuten gültig res.redirect(session.url); } // Plan-Upgrade/Downgrade kommt als customer.subscription.updated Event zurück // Stripe berechnet Proration automatisch — kein eigener Code nötig!

PORTAL Auf Portal-Events reagieren

// Wenn Nutzer im Portal seinen Plan ändert oder kündigt: // → customer.subscription.updated Event kommt // → handleSubscriptionUpdated() verarbeitet den neuen Status async function handleSubscriptionUpdated(subscription: Stripe.Subscription) { const newStatus = mapSubscriptionStatus(subscription.status); const newPriceId = subscription.items.data[0]?.price.id; // cancel_at_period_end: Nutzer hat gekündigt, Zugang läuft noch const cancelAtPeriodEnd = subscription.cancel_at_period_end; const client = await db.pool.connect(); try { await client.query('BEGIN'); await client.query( `UPDATE subscriptions SET status = $1, stripe_price_id = $2, current_period_end = $3, cancel_at_period_end = $4, updated_at = NOW() WHERE stripe_subscription_id = $5`, [ newStatus, newPriceId, new Date(subscription.current_period_end * 1000), cancelAtPeriodEnd, subscription.id ] ); await updateUserFeatures(client, subscription.id, newPriceId, newStatus); await client.query('COMMIT'); // Wenn Kündigung geplant: Retention-Email senden if (cancelAtPeriodEnd) { const user = await getUserBySubscription(subscription.id); await emailService.sendCancellationRetention(user, subscription.cancel_at!); } } catch (err) { await client.query('ROLLBACK'); throw err; } finally { client.release(); } }

5. Metered Billing: Usage Records, Billing Meter API und Verbrauchsreporting

Metered Billing erlaubt nutzungsbasierte Abrechnung — ideal für API-Calls, AI-Tokens, Speicher oder Transaktionen. Stripe's neue Billing Meter API (2024) ersetzt die älteren Usage Records und bietet bessere Aggregation und Real-Time Monitoring.

METER Billing Meter erstellen und Usage reporten

// Prompt: // "Stripe Billing Meter API für API-Call-Tracking: // - Meter für 'api_calls' erstellen // - Bei jedem API-Call: stripe.billing.meters.createEvent() aufrufen // - Aggregation: sum (count calls), event_name: 'api_call'" // Schritt 1: Meter einmalig erstellen (oder im Dashboard) async function createApiCallMeter() { const meter = await stripe.billing.meters.create({ display_name: 'API Calls', event_name: 'api_call', default_aggregation: { formula: 'sum' }, customer_mapping: { event_payload_key: 'stripe_customer_id', type: 'by_id' }, value_settings: { event_payload_key: 'value' } }); console.log(`Meter created: ${meter.id}`); return meter.id; } // Schritt 2: Usage-Event bei jedem API-Call reporten async function recordApiCall(userId: string, callCount: number = 1) { const user = await getUserById(userId); if (!user.stripe_customer_id) return; // Free-Tier: nicht tracken await stripe.billing.meterEvents.create({ event_name: 'api_call', payload: { stripe_customer_id: user.stripe_customer_id, value: String(callCount), // Muss String sein! }, timestamp: Math.floor(Date.now() / 1000), // Unix Timestamp }); } // Schritt 3: Aktuellen Verbrauch abrufen (für Dashboard-Anzeige) async function getCurrentUsage(customerId: string): Promise<number> { const now = Math.floor(Date.now() / 1000); const periodStart = 1735689600; // Start des aktuellen Billing-Periods const summary = await stripe.billing.meters.listEventSummaries( process.env.STRIPE_METER_ID!, { customer: customerId, start_time: periodStart, end_time: now, } ); const total = summary.data.reduce((sum, s) => sum + s.aggregated_value, 0); return total; }

METER Middleware: API-Calls automatisch tracken

// Prompt: // "Express Middleware die jeden API-Call automatisch trackt. // Tracking async im Hintergrund — niemals Request verlangsamen. // Rate-Limiting: wenn User über Limit → 429 mit Upgrade-Link." export function usageTrackingMiddleware(req, res, next) { // Usage tracking NACH Response — niemals Latency erhöhen res.on('finish', () => { if (res.statusCode < 400 && req.user?.id) { // Fire-and-forget: Fehler loggen, aber Request nicht blockieren recordApiCall(req.user.id).catch(err => console.error('Usage tracking failed:', err) ); } }); next(); } export async function checkUsageLimit(req, res, next) { const user = req.user; if (!user || user.plan === 'free') return next(); // Cached Usage aus Redis (max 1 Min veraltet) const cacheKey = `usage:${user.id}:${getCurrentBillingPeriod()}`; let usage = await redis.get(cacheKey); if (!usage) { const count = await getCurrentUsage(user.stripe_customer_id); usage = String(count); await redis.setex(cacheKey, 60, usage); // 1 Minute cachen } const limit = PLAN_LIMITS[user.plan] ?? 1000; if (parseInt(usage) >= limit) { return res.status(429).json({ error: 'Usage limit reached', limit, used: parseInt(usage), upgrade_url: `${process.env.APP_URL}/billing/portal` }); } next(); }
Metered Billing und Webhooks: Am Ende jeder Billing-Periode erstellt Stripe eine Invoice basierend auf dem Meter-Verbrauch. Das löst invoice.created → invoice.paid aus — die oben implementierten Handler verarbeiten das automatisch. Kein zusätzlicher Code nötig.

6. Testing: stripe listen CLI, Test-Events und Retry-Simulation

Webhooks sind schwer zu testen weil sie von Stripe kommen — nicht von deinem Code. Die Stripe CLI löst dieses Problem mit lokalem Forwarding und Event-Triggering. Claude Code schreibt die Test-Suites direkt mit.

TEST Stripe CLI: lokales Webhook-Forwarding

# stripe listen — leitet Stripe-Events an lokalen Server weiter # Gibt STRIPE_WEBHOOK_SECRET aus — für lokale .env verwenden! stripe listen \ --forward-to localhost:3000/webhook \ --events customer.subscription.created,customer.subscription.updated,\ customer.subscription.deleted,invoice.paid,invoice.payment_failed,\ checkout.session.completed # Output: # > Ready! Your webhook signing secret is whsec_test_abc123... # → In .env: STRIPE_WEBHOOK_SECRET=whsec_test_abc123... # Test-Events triggern (in zweitem Terminal): stripe trigger customer.subscription.created stripe trigger invoice.payment_failed stripe trigger checkout.session.completed # Spezifisches Szenario: Subscription gekündigt nach Trial stripe trigger customer.subscription.deleted \ --override subscription:trial_end=$(date -d '+7 days' +%s)

TEST Integration-Tests mit Jest und Stripe Test-Modus

// Prompt: // "Jest Integration-Tests für Webhook-Handler: // - stripe.webhooks.generateTestHeaderString() für gültige Signatur // - DB-Fixtures für Test-User mit stripe_customer_id // - Prüfen ob subscription status korrekt in DB gesetzt wird" import Stripe from 'stripe'; import supertest from 'supertest'; import { app } from '../app'; import { db } from '../db'; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); const webhookSecret = 'whsec_test_secret'; function buildWebhookRequest(event: object) { const payload = JSON.stringify(event); const timestamp = Math.floor(Date.now() / 1000); const signature = stripe.webhooks.generateTestHeaderString({ payload, secret: webhookSecret, timestamp }); return { payload, signature }; } describe('Stripe Webhook Handler', () => { let testUser: any; beforeEach(async () => { testUser = await db.query( "INSERT INTO users (email, stripe_customer_id) VALUES ($1, $2) RETURNING *", ['test@example.com', 'cus_test123'] ).then(r => r.rows[0]); }); afterEach(async () => { await db.query('DELETE FROM users WHERE id = $1', [testUser.id]); await db.query('DELETE FROM processed_events WHERE stripe_event_id LIKE $1', ['evt_test%']); }); it('creates subscription on customer.subscription.created', async () => { const event = { id: 'evt_test_001', type: 'customer.subscription.created', data: { object: { id: 'sub_test123', customer: 'cus_test123', status: 'active', items: { data: [{ price: { id: process.env.STRIPE_PRICE_PRO_MONTHLY } }] }, current_period_start: Math.floor(Date.now() / 1000), current_period_end: Math.floor(Date.now() / 1000) + 2592000, trial_end: null, cancel_at_period_end: false } } }; const { payload, signature } = buildWebhookRequest(event); const response = await supertest(app) .post('/webhook') .set('stripe-signature', signature) .set('Content-Type', 'application/json') .send(Buffer.from(payload)); expect(response.status).toBe(200); expect(response.body.received).toBe(true); // Warten bis BullMQ Worker verarbeitet hat await new Promise(r => setTimeout(r, 500)); const sub = await db.query( 'SELECT * FROM subscriptions WHERE stripe_subscription_id = $1', ['sub_test123'] ); expect(sub.rows[0].status).toBe('pro'); }); it('ignores duplicate events (idempotency)', async () => { // Event bereits in processed_events await db.query( 'INSERT INTO processed_events (stripe_event_id, event_type, received_at) VALUES ($1, $2, NOW())', ['evt_duplicate_001', 'invoice.paid'] ); const event = { id: 'evt_duplicate_001', type: 'invoice.paid', data: { object: {} } }; const { payload, signature } = buildWebhookRequest(event); const response = await supertest(app) .post('/webhook') .set('stripe-signature', signature) .send(Buffer.from(payload)); expect(response.status).toBe(200); expect(response.body.duplicate).toBe(true); }); });

TEST Webhook-Retry-Simulation und Failure-Handling

# Stripe retried fehlgeschlagene Webhooks bis zu 25x über 3 Tage # Retry-Zeitplan: 5s, 30s, 5m, 30m, 1h, 2h, 4h, 8h, 16h, 32h... # Lokale Retry-Simulation: stripe trigger invoice.payment_failed --skip-verify # → Server returned 500 # → stripe CLI zeigt "Webhook delivery failed, will retry" # Im Stripe Dashboard (Test-Modus): # Developers → Webhooks → Webhook-Endpoint → Events → "Resend" # Alle fehlgeschlagenen Events können manuell resent werden # WICHTIG: stripe listen speichert Events lokal # Offline-Periode → Events werden nach Reconnect nachgeliefert stripe listen --forward-to localhost:3000/webhook --reconnect

Security Checklist für Stripe Webhooks

  • Signature immer verifikieren: stripe.webhooks.constructEvent() mit STRIPE_WEBHOOK_SECRET. Niemals Events ohne Verifikation verarbeiten.
  • Raw Body verwenden: express.raw() für Webhook-Route. JSON.parse() zerstört die HMAC-Verifikation.
  • Idempotenz via event.id: processed_events Tabelle mit UNIQUE Constraint auf stripe_event_id.
  • Sofort 200 antworten: Stripe wartet max. 30 Sekunden. Schwere Verarbeitung immer in Queue auslagern.
  • DB-Transaktionen: Subscription-Updates immer in einer Transaktion — kein Partial-State in der DB.
  • Separates Webhook-Secret: Für lokale Entwicklung (stripe listen) und Production unterschiedliche Secrets. Nie das gleiche verwenden.
  • Niemals: Stripe Event-Daten blind vertrauen ohne den zugehörigen Stripe-Objekt per API zu verifizieren (bei kritischen Actions).
  • Niemals: 500 bei Verarbeitungsfehlern zurückgeben (löst Retry-Storm aus). Lieber 200 + Error-Logging + Alerting.
Kritischer Fehler: 500 zurückgeben
Wenn dein Webhook-Endpoint einen 500-Fehler zurückgibt, retried Stripe das Event sofort — und immer wieder für 72 Stunden. Kombiniert mit langer Verarbeitungszeit entstehen so hunderte Duplikat-Events. Regel: Signatur-Fehler → 400. Alles andere → 200 + Fehler intern loggen + Alerting.

Claude Code Prompts: Stripe Webhook Best Practices

Bewährte Claude Code Prompts für Webhook-Integration

Diese Prompts liefern Production-ready Code für die häufigsten Szenarien:

# Kompletter Webhook-Stack in einem Prompt: "Implementiere vollständigen Stripe Webhook Stack für SaaS: - Express Middleware: raw body für /webhook, JSON für alles andere - constructEvent Verifikation mit STRIPE_WEBHOOK_SECRET aus .env - processed_events Tabelle mit stripe_event_id UNIQUE (PostgreSQL Migration mitliefern) - BullMQ Queue 'stripe-events' mit Redis Connection aus REDIS_URL - Worker mit Switch auf alle Standard-Subscription/Invoice Events - Typisierung mit Stripe TypeScript SDK (stripe@latest) - Jest Tests für alle Handler mit generateTestHeaderString()" # Nur Dunning Management: "invoice.payment_failed Handler mit 3-Stage Dunning: Stage 1 (1. Fehlschlag): Email mit direktem Link zur Zahlungsmethoden-Änderung Stage 2 (2. Fehlschlag): Email + In-App Banner + Slack Alert an Customer Success Stage 3 (3. Fehlschlag): Email Suspension-Ankündigung mit genauem Datum payment_failed_count und last_payment_failed_at in subscriptions Tabelle tracken" # Customer Portal mit Retention: "Stripe Customer Portal Integration mit Cancellation-Flow: - Portal Session mit return_url - Bei cancel_at_period_end=true: Retention-Email mit 'Wir möchten dich behalten' + Discount-Angebot - Cancellation-Reason aus subscription.cancellation_details in DB speichern - Churn-Dashboard: Aggregate nach Cancellation-Reason"

Zahlungsintegration die wirklich funktioniert

Claude Code kennt alle Stripe Best Practices — Webhook-Verifikation, Idempotenz, Dunning, Metered Billing. Starte mit 14 Tagen kostenlos und integriere Stripe in Stunden statt Wochen.

14 Tage kostenlos testen

Fazit: Robuste Webhooks mit Claude Code

Stripe Webhooks sind mächtiger als die meisten Entwickler ahnen — aber auch komplexer als ein einfacher POST-Endpoint. Die kritischen Punkte: Signature-Verifikation (niemals skippen), Idempotenz via event.id (at-least-once delivery ist Stripe-Standard), sofort 200 antworten (Arbeit in Queue auslagern), und DB-Transaktionen für Subscription-Updates (kein Partial-State). Mit Claude Code implementierst du diese Patterns korrekt in einem einzigen Prompt — statt sie nach dem ersten Production-Incident mühsam nachzurüsten. Das Dunning Management, Customer Portal und Metered Billing kommen on top: drei Systeme die Churn reduzieren und Revenue maximieren. Alle sind Event-getrieben über dieselbe Webhook-Infrastruktur.