Stripe Billing-Implementierung: Abonnements, Rechnungen und Webhooks
Stripe ist der De-facto-Standard für die Abrechnungsinfrastruktur, aber seine korrekte Implementierung – idempotente Handhabung von Webhooks, Verwaltung von Abonnementstatusübergängen und Wiederherstellung nach fehlgeschlagenen Zahlungen – erfordert wesentlich mehr Sorgfalt, als die Dokumentation zu den ersten Schritten vermuten lässt. Ein Zahlungssystem, das 99 % der Zeit funktioniert und das andere 1 % lautlos ausfällt, ist schlimmer als eines, das zu 95 % laut ausfällt.
Dieser Leitfaden behandelt eine Produktions-Stripe-Implementierung mit 6 Webhook-Ereignistypen, Abonnement-Lebenszyklusverwaltung, automatischer Lizenzbereitstellung und Rückerstattungsabwicklung. Die Codebeispiele verwenden NestJS 11, aber die Muster gelten für jedes Backend-Framework.
Wichtige Erkenntnisse
– Gewähren Sie niemals Zugriff basierend auf der Checkout-Umleitung – warten Sie immer auf den
checkout.session.completed-Webhook – Überprüfen Sie die Webhook-Signaturen mitstripe.webhooks.constructEvent(), bevor Sie ein Ereignis verarbeiten – Machen Sie alle Webhook-Handler idempotent – Stripe wiederholt fehlgeschlagene Webhooks bis zu 72 Stunden lang
- Speichern Sie Stripe-Kunden-IDs, Abonnement-IDs und Preis-IDs in Ihrer Datenbank – fragen Sie Stripe nicht erneut nach allem ab – Behandeln Sie
invoice.payment_failed, um Mahn-E-Mails und eine Abonnementsperre auszulösencharge.refundedundcheckout.session.completeddecken zusammen den gesamten Kauflebenszyklus ab- Verwenden Sie das Feld
metadatavon Stripe, um Stripe-Objekte mit Ihren internen Datensätzen zu verknüpfen- Protokollieren Sie niemals unformatierte Stripe-Webhook-Payloads – sie enthalten vertrauliche Kartendaten
Projekt-Setup
pnpm add stripe
// billing/stripe.client.ts
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-12-18.acacia',
typescript: true,
});
Speichern Sie diese Umgebungsvariablen:
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PUBLISHABLE_KEY=pk_live_...
Erstellung einer Checkout-Sitzung
Der Checkout-Ablauf: Ihr Backend erstellt eine Stripe Checkout-Sitzung, das Frontend leitet zu Stripe weiter, Stripe kassiert die Zahlung und ruft dann Ihren Webhook auf. Erledigen Sie die Zahlung niemals selbst – leiten Sie immer zu Stripe Checkout weiter.
// billing/billing.service.ts
import Stripe from 'stripe';
import { Injectable, BadRequestException } from '@nestjs/common';
import { stripe } from './stripe.client';
@Injectable()
export class BillingService {
async createCheckoutSession(
userId: string,
orgId: string,
priceId: string,
productId: string
) {
// Get or create Stripe customer
const customer = await this.getOrCreateCustomer(userId, orgId);
const session = await stripe.checkout.sessions.create({
customer: customer.stripeCustomerId,
payment_method_types: ['card'],
line_items: [
{
price: priceId,
quantity: 1,
},
],
mode: 'payment', // or 'subscription' for recurring
success_url: `${process.env.FRONTEND_URL}/dashboard/billing?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.FRONTEND_URL}/services`,
metadata: {
userId,
organizationId: orgId,
productId,
},
allow_promotion_codes: true,
invoice_creation: {
enabled: true,
},
});
return { url: session.url };
}
async createSubscriptionCheckout(
userId: string,
orgId: string,
priceId: string,
productId: string
) {
const customer = await this.getOrCreateCustomer(userId, orgId);
const session = await stripe.checkout.sessions.create({
customer: customer.stripeCustomerId,
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
mode: 'subscription',
subscription_data: {
metadata: {
userId,
organizationId: orgId,
productId,
},
trial_period_days: 14,
},
success_url: `${process.env.FRONTEND_URL}/dashboard/billing?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.FRONTEND_URL}/pricing`,
metadata: {
userId,
organizationId: orgId,
productId,
},
});
return { url: session.url };
}
private async getOrCreateCustomer(userId: string, orgId: string) {
// Check if customer already exists in your DB
const existing = await db.query.billingCustomers.findFirst({
where: and(
eq(billingCustomers.userId, userId),
eq(billingCustomers.organizationId, orgId)
),
});
if (existing) return existing;
// Fetch user details for Stripe customer creation
const user = await db.query.users.findFirst({
where: eq(users.id, userId),
});
const stripeCustomer = await stripe.customers.create({
email: user.email,
name: user.name,
metadata: {
userId,
organizationId: orgId,
},
});
const [customer] = await db
.insert(billingCustomers)
.values({
userId,
organizationId: orgId,
stripeCustomerId: stripeCustomer.id,
})
.returning();
return customer;
}
}
Webhook-Handler
Der Webhook-Handler ist der kritischste Teil der Stripe-Integration. Jedes Ereignis muss idempotent sein:
// billing/webhook.controller.ts
import {
Controller,
Post,
Req,
Headers,
RawBodyRequest,
HttpCode,
} from '@nestjs/common';
import { Request } from 'express';
import Stripe from 'stripe';
import { stripe } from './stripe.client';
import { Public } from '../auth/decorators/public.decorator';
import { BillingWebhookService } from './billing-webhook.service';
@Controller('billing/webhook')
export class WebhookController {
constructor(private webhookService: BillingWebhookService) {}
@Post()
@Public()
@HttpCode(200) // Always return 200 — Stripe retries on non-2xx
async handleWebhook(
@Req() req: RawBodyRequest<Request>,
@Headers('stripe-signature') signature: string
) {
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.rawBody!, // Requires raw body middleware
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
// Invalid signature — do not process
throw new Error(`Webhook signature verification failed: ${err.message}`);
}
// Route to specific handler
switch (event.type) {
case 'checkout.session.completed':
await this.webhookService.handleCheckoutCompleted(
event.data.object as Stripe.Checkout.Session
);
break;
case 'invoice.paid':
await this.webhookService.handleInvoicePaid(
event.data.object as Stripe.Invoice
);
break;
case 'invoice.payment_failed':
await this.webhookService.handlePaymentFailed(
event.data.object as Stripe.Invoice
);
break;
case 'customer.subscription.deleted':
await this.webhookService.handleSubscriptionDeleted(
event.data.object as Stripe.Subscription
);
break;
case 'customer.subscription.updated':
await this.webhookService.handleSubscriptionUpdated(
event.data.object as Stripe.Subscription
);
break;
case 'charge.refunded':
await this.webhookService.handleChargeRefunded(
event.data.object as Stripe.Charge
);
break;
default:
// Ignore unhandled event types
break;
}
return { received: true };
}
}
Wichtig: NestJS benötigt rawBody: true in NestFactory.create() für die Stripe-Signaturüberprüfung:
// main.ts
const app = await NestFactory.create(AppModule, {
rawBody: true,
});
Checkout Completed Handler
Dies ist der zentrale Bereitstellungshandler. Machen Sie es idempotent:
// billing/billing-webhook.service.ts
@Injectable()
export class BillingWebhookService {
async handleCheckoutCompleted(session: Stripe.Checkout.Session) {
// Check if already processed (idempotency)
const existing = await db.query.orders.findFirst({
where: eq(orders.stripeSessionId, session.id),
});
if (existing) {
return; // Already processed — Stripe is retrying
}
const { userId, organizationId, productId } = session.metadata!;
// Create order in transaction
await db.transaction(async (tx) => {
// Create order
const [order] = await tx
.insert(orders)
.values({
userId,
organizationId,
productId,
stripeSessionId: session.id,
stripePaymentIntentId: session.payment_intent as string,
amount: session.amount_total! / 100, // Stripe uses cents
currency: session.currency!,
status: 'completed',
})
.returning();
// Generate license key
const licenseKey = generateLicenseKey(productId);
// Create license
await tx.insert(licenses).values({
orderId: order.id,
userId,
organizationId,
productId,
licenseKey,
status: 'active',
issuedAt: new Date(),
});
});
// Send confirmation email (non-blocking — don't fail the webhook)
this.emailService
.sendPurchaseConfirmation(userId, productId)
.catch((err) => this.logger.error('Email send failed', err));
}
async handleInvoicePaid(invoice: Stripe.Invoice) {
// For subscriptions — renewal
if (!invoice.subscription) return;
// Find existing subscription/license and extend it
const license = await db.query.licenses.findFirst({
where: eq(licenses.stripeSubscriptionId, invoice.subscription as string),
});
if (!license) return;
await db
.update(licenses)
.set({
status: 'active',
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // +30 days
})
.where(eq(licenses.id, license.id));
// Log renewal in audit table
await db.insert(licenseAuditLogs).values({
licenseId: license.id,
action: 'renewed',
details: { invoiceId: invoice.id },
});
}
async handlePaymentFailed(invoice: Stripe.Invoice) {
if (!invoice.subscription) return;
const license = await db.query.licenses.findFirst({
where: eq(licenses.stripeSubscriptionId, invoice.subscription as string),
});
if (!license) return;
// Grace period before suspension (e.g., 3 days)
const gracePeriod = new Date();
gracePeriod.setDate(gracePeriod.getDate() + 3);
if (new Date() > gracePeriod) {
await db
.update(licenses)
.set({ status: 'suspended' })
.where(eq(licenses.id, license.id));
}
// Send dunning email
this.emailService
.sendPaymentFailedNotification(license.userId, invoice)
.catch(console.error);
}
async handleChargeRefunded(charge: Stripe.Charge) {
const order = await db.query.orders.findFirst({
where: eq(orders.stripePaymentIntentId, charge.payment_intent as string),
});
if (!order) return;
await db.transaction(async (tx) => {
// Update order status
await tx
.update(orders)
.set({ status: 'refunded' })
.where(eq(orders.id, order.id));
// Revoke associated licenses
await tx
.update(licenses)
.set({ status: 'revoked', revokedAt: new Date() })
.where(eq(licenses.orderId, order.id));
});
// Log audit trail
await db.insert(licenseAuditLogs).values({
licenseId: (await db.query.licenses.findFirst({
where: eq(licenses.orderId, order.id),
}))!.id,
action: 'revoked',
details: { reason: 'refund', chargeId: charge.id },
});
}
async handleSubscriptionDeleted(subscription: Stripe.Subscription) {
await db
.update(licenses)
.set({ status: 'cancelled', cancelledAt: new Date() })
.where(eq(licenses.stripeSubscriptionId, subscription.id));
}
async handleSubscriptionUpdated(subscription: Stripe.Subscription) {
const status = subscription.status === 'active' ? 'active' : 'suspended';
await db
.update(licenses)
.set({ status })
.where(eq(licenses.stripeSubscriptionId, subscription.id));
}
}
Kundenportal
Ermöglichen Sie Kunden, ihre eigenen Abonnements zu verwalten, ohne den Support zu kontaktieren:
async createPortalSession(userId: string, orgId: string) {
const customer = await db.query.billingCustomers.findFirst({
where: and(
eq(billingCustomers.userId, userId),
eq(billingCustomers.organizationId, orgId)
),
});
if (!customer) {
throw new NotFoundException('No billing account found');
}
const portalSession = await stripe.billingPortal.sessions.create({
customer: customer.stripeCustomerId,
return_url: `${process.env.FRONTEND_URL}/dashboard/billing`,
});
return { url: portalSession.url };
}
Über das Kundenportal können Benutzer Abonnements kündigen, Zahlungsmethoden aktualisieren, Rechnungen herunterladen und den Rechnungsverlauf anzeigen – ohne dass eine benutzerdefinierte Benutzeroberfläche erforderlich ist.
Frontend-Integration
// web: lib/billing.ts
'use client';
import { apiFetch } from '@/lib/api';
export async function initiateCheckout(priceId: string, productId: string) {
const { url } = await apiFetch<{ url: string }>('/billing/checkout', {
method: 'POST',
body: JSON.stringify({ priceId, productId }),
});
// Redirect to Stripe Checkout
window.location.href = url;
}
export async function openCustomerPortal() {
const { url } = await apiFetch<{ url: string }>('/billing/portal', {
method: 'POST',
});
window.open(url, '_blank');
}
Webhooks lokal testen
Die Stripe-CLI ist für die lokale Webhook-Entwicklung unerlässlich:
# Install Stripe CLI
npm install -g stripe
# Listen and forward webhooks to localhost
stripe listen --forward-to localhost:3001/billing/webhook
# Trigger test events
stripe trigger checkout.session.completed
stripe trigger invoice.paid
stripe trigger charge.refunded
stripe trigger customer.subscription.deleted
# View event logs
stripe events list
Die CLI stellt Ihr lokales Webhook-Geheimnis bereit – verwenden Sie es in Ihrem .env.local:
STRIPE_WEBHOOK_SECRET=whsec_test_... # From `stripe listen` output
Häufige Fallstricke und Lösungen
Falle 1: Bereitstellung des Zugriffs über die Checkout-Umleitung statt über den Webhook
Die Weiterleitung bei erfolgreichem Bezahlvorgang kann ausgelöst werden durch: Browser zurück/vor, doppelte Tabs oder Netzwerkwiederholung. Stellen Sie immer basierend auf dem checkout.session.completed-Webhook bereit, nicht auf der Weiterleitungs-URL.
Falle 2: Webhook-Wiederholungsversuche werden nicht verarbeitet (Idempotenz)
Stripe versucht 72 Stunden lang fehlgeschlagene Webhooks erneut. Ohne Idempotenzprüfungen führen Wiederholungsversuche zu doppelten Bestellungen, Lizenzen und E-Mails. Speichern Sie die Stripe-Sitzungs-ID mit einer eindeutigen Einschränkung in Ihrer Auftragstabelle – doppelte Einfügungen schlagen ordnungsgemäß fehl.
Falle 3: Falsche Verwendung der Raw-Body-Middleware
Für die Überprüfung der Stripe-Signatur ist der rohe, ungeparste Anforderungstext erforderlich. Wenn der JSON-Body-Parser von Express zuerst ausgeführt wird, ist der Rohkörper verschwunden. Verwenden Sie rawBody: true in NestJS und greifen Sie auf req.rawBody zu oder konfigurieren Sie die JSON-Middleware, um den Rohtext speziell für die Webhook-Route beizubehalten.
Falle 4: Fehlende Wiederholungslogik für Stripe-API-Aufrufe
Stripe-API-Aufrufe können vorübergehend fehlschlagen. Wiederholungslogik für idempotente Vorgänge hinzufügen:
import Stripe from 'stripe';
async function retryStripeOperation<T>(
operation: () => Promise<T>,
maxRetries = 3
): Promise<T> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await operation();
} catch (err) {
if (err instanceof Stripe.errors.StripeConnectionError && attempt < maxRetries) {
await new Promise((r) => setTimeout(r, 1000 * attempt));
continue;
}
throw err;
}
}
throw new Error('Max retries exceeded');
}
Häufig gestellte Fragen
Soll ich Stripe Checkout oder benutzerdefinierte Zahlungsformulare verwenden?
Verwenden Sie immer Stripe Checkout, es sei denn, Sie haben spezielle Anforderungen, die dies unmöglich machen (selten). Checkout übernimmt die PCI-Konformität, die 3D-Secure-Authentifizierung, Apple Pay, Google Pay, SEPA und Dutzende anderer Zahlungsmethoden automatisch. Benutzerdefinierte Zahlungsformulare (mit Stripe Elements) erfordern, dass Sie die PCI-Konformität selbst einhalten und jede Zahlungsmethode manuell implementieren. Für 95 % der SaaS-Produkte ist Checkout die richtige Wahl.
Wie gehe ich mit dem Testmodus von Stripe im Vergleich zum Live-Modus um?
Verwenden Sie separate Umgebungskonfigurationen: STRIPE_SECRET_KEY=sk_test_... in der Entwicklung und STRIPE_SECRET_KEY=sk_live_... in der Produktion. Der Stripe-Testmodus ist vollständig vom Live-Modus isoliert – keine Testgebühren wirken sich auf echtes Geld aus und im Testmodus werden keine Live-Daten angezeigt. Verwenden Sie stripe.mode in Ihrem Code, um zu überprüfen, ob Sie den richtigen Schlüssel verwenden, und fügen Sie in Ihrer Admin-Benutzeroberfläche eine auffällige Anzeige hinzu, die anzeigt, in welchem Modus Sie sich befinden.
Was passiert, wenn mein Webhook-Server ausgefallen ist und Stripe nicht liefern kann?
Stripe wiederholt fehlgeschlagene Webhooks nach einem exponentiellen Backoff-Zeitplan für bis zu 72 Stunden: 5 Minuten, 30 Minuten, 1 Stunde, 2 Stunden und zunehmende Intervalle. Nach 72 Stunden gilt die Veranstaltung als gescheitert. Verwenden Sie das Ereignis-Dashboard von Stripe, um fehlgeschlagene Ereignisse manuell erneut zu übermitteln, oder fragen Sie stripe.events.list() ab und spielen Sie sie erneut ab. Gestalten Sie Ihre Handler so, dass sie idempotent sind, sodass eine erneute Zustellung sicher ist.
Wie setze ich eine zählerbasierte/nutzungsbasierte Abrechnung um?
Stripe Billing unterstützt die gemessene Nutzung über stripe.subscriptionItems.createUsageRecord(). Melden Sie die Nutzung am Ende jedes Abrechnungszeitraums mit der verbrauchten Menge. Preisgemessene Pläne in Ihrem Stripe-Dashboard als „gemessene“ Preise. Das wichtigste Implementierungsdetail: Melden Sie die Nutzung vor dem Ende des Abrechnungszeitraums, nicht danach – Stripe schließt die Rechnung am Ende des Zeitraums ab.
Wie soll ich Stripe-IDs in meiner Datenbank speichern?
Speichern Sie alle Stripe-IDs als text-Spalten (nicht numerisch). Erstellen Sie Indizes für stripe_customer_id, stripe_subscription_id und stripe_payment_intent_id – Sie werden anhand dieser Datensätze häufig nachschlagen. Fügen Sie gegebenenfalls eindeutige Einschränkungen hinzu (ein Kundendatensatz pro Benutzer). Speichern Sie niemals Kreditkartennummern oder Rohzahlungsdaten in Ihrer eigenen Datenbank.
Nächste Schritte
Die korrekte Implementierung der Stripe-Abrechnung – mit Webhook-Idempotenz, Abonnementlebenszyklusverwaltung, automatischer Bereitstellung und ordnungsgemäßer Rückerstattungsabwicklung – ist ein nicht triviales technisches Unterfangen. ECOSIRE verarbeitet 6 Stripe-Webhook-Ereignistypen in der Produktion mit automatischer Lizenzbereitstellung und Kundenportalintegration.
Wenn Sie eine Stripe-Integration, eine Abonnement-Abrechnungsinfrastruktur oder Full-Stack-SaaS-Entwicklungsunterstützung benötigen, entdecken Sie unsere Entwicklungsdienste.
Geschrieben von
ECOSIRE Research and Development Team
Entwicklung von Enterprise-Digitalprodukten bei ECOSIRE. Einblicke in Odoo-Integrationen, E-Commerce-Automatisierung und KI-gestützte Geschäftslösungen.
Verwandte Artikel
ERP for Law Firms: Case Management, Billing, and Client Portal
Complete guide to ERP for law firms — case management, legal billing, trust accounting, IOLTA compliance, client portal, and matter management for 2026.
Media Company ERP Implementation: Editorial, Ad Sales, and Subscriptions
Step-by-step guide to implementing ERP in media companies, covering editorial workflow integration, advertising system migration, subscription billing setup, and data migration.
Professional Services ERP Implementation: Time, Billing, and Projects
Step-by-step professional services ERP implementation guide covering time tracking, billing configuration, project accounting, and resource management setup.