Stripe-Abonnements mit Claude Code implementieren
Stripe-Abos mit Claude Code umsetzen: Checkout, Portal, Webhooks, Berechtigungen und SaaS-Monetarisierung.
Ein Abo ist ein Umsatzprozess, kein einzelner Button
Eine Stripe-Checkout-Session ist schnell erstellt. Für ein echtes SaaS, eine Mitgliederseite, eine Vorlagenbibliothek oder ein wiederkehrendes Beratungsangebot reicht das aber nicht. Du musst Zahlungsfehler, Planwechsel, Kündigung zum Periodenende, Rechnungen, Customer Portal, Webhook-Wiederholungen, Berechtigungen und Logs sauber abbilden.
Claude Code hilft dabei, wenn du es nicht mit einem vagen Auftrag wie “Stripe einbauen” startest. Gib dem Agenten offizielle Dokumentation, ein Datenbankschema, klare Statusregeln, lokale Testbefehle und Review-Kriterien. Dann entsteht Code, der näher an Produktionsanforderungen liegt und nicht nur eine Demo ist.
Die wichtigste Trennung lautet: Stripe ist die Quelle der Wahrheit für Abrechnung und Subscription-Status. Deine Anwendung speichert, was der Nutzer aktuell verwenden darf. Stripe kennt active, trialing, past_due, unpaid und canceled; deine App entscheidet über templates:download, team:seats oder review:request.
flowchart LR
A["Pricing-Seite"] --> B["Checkout Session<br/>mode=subscription"]
B --> C["Stripe Subscription"]
C --> D["Signierter Webhook"]
D --> E["billing_subscriptions"]
D --> F["entitlements"]
F --> G["Feature Gate"]
G --> H["SaaS, Kurs oder Produktbibliothek"]
H --> I["Customer Portal"]
I --> C
Preise und Berechtigungen zuerst festlegen
Bevor Claude Code Dateien erzeugt, sollte die Monetarisierung als Tabelle vorliegen.
| Plan | Zielgruppe | Monatlich Beispiel | Jährlich Beispiel | Berechtigungen |
|---|---|---|---|---|
| Free | Besucher, die Inhalte testen | 0 € | 0 € | Vorschau, Beispieldownloads |
| Pro | Solo-Entwickler, Template-Käufer | 19 € | 190 € | Vollständige Artikel, Templates, Kursbibliothek |
| Studio | Teams und wiederkehrende Kunden | 99 € | 990 € | Pro, Team-Sitze, Review-Anfragen, Trainingsmaterial |
Mindestens drei konkrete Anwendungsfälle machen die Lücken sichtbar. Erstens: ein Micro-SaaS, bei dem Exporte, Dashboards und Automatisierung nur im Bezahlplan aktiv sind. Zweitens: ein Info-Produkt-Funnel von kostenlosem Artikel zu Template-Bibliothek und Mitgliedschaft. Drittens: Beratung oder Training mit Team-Sitzen, monatlichen Review-Slots und wiederkehrender Rechnung. Diese Fälle zwingen dich, Kündigung, fehlgeschlagene Zahlungen und Rechteentzug sauber zu behandeln.
Ein entitlement ist eine Berechtigung, also ein konkretes Nutzungsrecht. Dunning beschreibt den Prozess nach fehlgeschlagener Zahlung: Erinnerungen, erneute Zahlungsversuche und Link zum Aktualisieren der Zahlungsmethode. Observability heißt, dass du nachvollziehen kannst, welches Stripe-Event welchen Nutzerstatus geändert hat.
Claude Code mit offiziellen Quellen anweisen
Bei Stripe-spezifischen Aussagen solltest du die aktuellen offiziellen Dokumente als Grundlage verwenden.
Implementiere Stripe-Billing-Abonnements für eine Next.js-App mit App Router.
Nutze diese offiziellen Dokumente als Quelle:
- Checkout Sessions API: https://docs.stripe.com/api/checkout/sessions/create
- Customer Portal: https://docs.stripe.com/customer-management
- Subscription webhooks: https://docs.stripe.com/billing/subscriptions/webhooks
- Webhooks and local testing: https://docs.stripe.com/webhooks
- Subscription object and statuses: https://docs.stripe.com/api/subscriptions/object
Anforderungen:
- TypeScript, Next.js App Router, Postgres
- Checkout mit mode="subscription"
- Customer Portal für Zahlungsmethode, Rechnungen, Planwechsel und Kündigung
- Webhook mit Signaturprüfung und Deduplication per event_id
- Lokale entitlements-Tabelle entscheidet über App-Zugriff
- active/trialing geben Zugriff, past_due hat 3 Tage Kulanz, unpaid/canceled/paused sperren
- Kopierbaren Code und stripe-listen-Befehle liefern
Postgres-Schema
billing_subscriptions spiegelt Stripe. entitlements ist die Tabelle, die deine App vor Premiumfunktionen liest. webhook_events verhindert doppelte Verarbeitung.
create table if not exists app_users (
id text primary key,
email text not null unique,
stripe_customer_id text unique,
created_at timestamptz not null default now()
);
create table if not exists billing_subscriptions (
user_id text primary key references app_users(id) on delete cascade,
stripe_customer_id text not null,
stripe_subscription_id text not null unique,
plan_key text not null,
status text not null check (
status in ('incomplete', 'incomplete_expired', 'trialing', 'active', 'past_due', 'canceled', 'unpaid', 'paused')
),
access_state text not null check (access_state in ('pending', 'granted', 'grace', 'revoked')),
current_period_end timestamptz,
cancel_at_period_end boolean not null default false,
grace_until timestamptz,
updated_at timestamptz not null default now()
);
create table if not exists entitlements (
user_id text not null references app_users(id) on delete cascade,
feature_key text not null,
active boolean not null default true,
expires_at timestamptz,
updated_at timestamptz not null default now(),
primary key (user_id, feature_key)
);
create table if not exists webhook_events (
event_id text primary key,
event_type text not null,
status text not null default 'processing',
attempts integer not null default 1,
processed_at timestamptz,
last_error text,
created_at timestamptz not null default now()
);
Checkout und Customer Portal
npm install stripe postgres
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRO_PRICE_ID=price_...
STRIPE_STUDIO_PRICE_ID=price_...
NEXT_PUBLIC_APP_URL=http://localhost:3000
DATABASE_URL=postgres://user:pass@localhost:5432/app
STRIPE_AUTOMATIC_TAX=false
// src/lib/db.ts
import postgres from "postgres";
export const sql = postgres(process.env.DATABASE_URL!, {
max: 5,
idle_timeout: 20,
});
// src/lib/billing.ts
import Stripe from "stripe";
import { sql } from "@/lib/db";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
export const PLANS = {
free: { priceId: null, features: ["article:preview"] },
pro: {
priceId: process.env.STRIPE_PRO_PRICE_ID!,
features: ["article:full", "templates:download", "course:library"],
},
studio: {
priceId: process.env.STRIPE_STUDIO_PRICE_ID!,
features: ["article:full", "templates:download", "course:library", "team:seats", "review:request"],
},
} as const;
export type PlanKey = keyof typeof PLANS;
export type PaidPlanKey = Exclude<PlanKey, "free">;
export function isPaidPlanKey(value: unknown): value is PaidPlanKey {
return value === "pro" || value === "studio";
}
export async function createCheckoutSession(userId: string, planKey: PaidPlanKey) {
const customerId = await findOrCreateCustomer(userId);
const plan = PLANS[planKey];
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: "subscription",
line_items: [{ price: plan.priceId, quantity: 1 }],
client_reference_id: userId,
allow_promotion_codes: true,
automatic_tax: { enabled: process.env.STRIPE_AUTOMATIC_TAX === "true" },
success_url: `${APP_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${APP_URL}/pricing`,
subscription_data: { metadata: { userId, planKey } },
metadata: { userId, planKey },
});
if (!session.url) throw new Error("Stripe did not return a Checkout URL");
return session.url;
}
export async function createPortalSession(userId: string) {
const customerId = await getCustomerId(userId);
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${APP_URL}/settings/billing`,
});
return session.url;
}
async function findOrCreateCustomer(userId: string) {
const users = await sql`select id, email, stripe_customer_id from app_users where id = ${userId}`;
const user = users[0] as { id: string; email: string; stripe_customer_id: string | null } | undefined;
if (!user) throw new Error("User not found");
if (user.stripe_customer_id) return user.stripe_customer_id;
const customer = await stripe.customers.create({ email: user.email, metadata: { userId } });
await sql`update app_users set stripe_customer_id = ${customer.id} where id = ${userId}`;
return customer.id;
}
async function getCustomerId(userId: string) {
const rows = await sql`select stripe_customer_id from app_users where id = ${userId}`;
const customerId = rows[0]?.stripe_customer_id as string | undefined;
if (!customerId) throw new Error("Stripe customer is not linked");
return customerId;
}
// src/app/api/billing/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createCheckoutSession, isPaidPlanKey } from "@/lib/billing";
export async function POST(request: NextRequest) {
const userId = request.headers.get("x-demo-user-id");
if (!userId) return NextResponse.json({ error: "Missing x-demo-user-id" }, { status: 401 });
const { planKey } = await request.json();
if (!isPaidPlanKey(planKey)) return NextResponse.json({ error: "Invalid plan" }, { status: 400 });
const url = await createCheckoutSession(userId, planKey);
return NextResponse.json({ url });
}
// src/app/api/billing/portal/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createPortalSession } from "@/lib/billing";
export async function POST(request: NextRequest) {
const userId = request.headers.get("x-demo-user-id");
if (!userId) return NextResponse.json({ error: "Missing x-demo-user-id" }, { status: 401 });
const url = await createPortalSession(userId);
return NextResponse.json({ url });
}
Webhooks und Berechtigungen synchronisieren
Die Success-URL ist kein Zahlungsnachweis. Sie ist nur der Rückweg für den Nutzer. Zugriff sollte über signierte Webhooks oder den aktuellen Stripe-API-Status gesetzt werden. Da Stripe-Events nicht zwingend in Reihenfolge eintreffen, liest der Code die Subscription erneut.
// src/lib/entitlements.ts
import Stripe from "stripe";
import { sql } from "@/lib/db";
import { PLANS, PlanKey, stripe } from "@/lib/billing";
type AccessState = "pending" | "granted" | "grace" | "revoked";
export async function syncSubscriptionFromStripe(subscriptionId: string) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId, { expand: ["items.data.price"] });
const item = subscription.items.data[0];
const planKey = planKeyFromPrice(item?.price.id);
const userId = subscription.metadata.userId;
if (!userId) throw new Error(`Subscription ${subscription.id} has no userId metadata`);
const accessState = accessStateFor(subscription.status);
const currentPeriodEnd = item?.current_period_end ? new Date(item.current_period_end * 1000) : null;
const graceUntil = accessState === "grace" ? new Date(Date.now() + 3 * 24 * 60 * 60 * 1000) : null;
await sql.begin(async (tx) => {
await tx`
insert into billing_subscriptions (
user_id, stripe_customer_id, stripe_subscription_id, plan_key,
status, access_state, current_period_end, cancel_at_period_end, grace_until, updated_at
)
values (
${userId}, ${subscription.customer as string}, ${subscription.id}, ${planKey},
${subscription.status}, ${accessState}, ${currentPeriodEnd},
${subscription.cancel_at_period_end}, ${graceUntil}, now()
)
on conflict (user_id) do update set
stripe_customer_id = excluded.stripe_customer_id,
stripe_subscription_id = excluded.stripe_subscription_id,
plan_key = excluded.plan_key,
status = excluded.status,
access_state = excluded.access_state,
current_period_end = excluded.current_period_end,
cancel_at_period_end = excluded.cancel_at_period_end,
grace_until = excluded.grace_until,
updated_at = now()
`;
const features = accessState === "granted" || accessState === "grace" ? PLANS[planKey].features : PLANS.free.features;
await tx`delete from entitlements where user_id = ${userId}`;
for (const feature of features) {
await tx`insert into entitlements (user_id, feature_key, active, expires_at, updated_at) values (${userId}, ${feature}, true, ${graceUntil}, now())`;
}
});
console.info("stripe.subscription.synced", { userId, subscriptionId: subscription.id, status: subscription.status, accessState, planKey });
}
function planKeyFromPrice(priceId: string | undefined): PlanKey {
const entry = Object.entries(PLANS).find(([, plan]) => plan.priceId === priceId);
return entry ? (entry[0] as PlanKey) : "free";
}
function accessStateFor(status: Stripe.Subscription.Status): AccessState {
if (status === "active" || status === "trialing") return "granted";
if (status === "past_due") return "grace";
if (status === "incomplete") return "pending";
return "revoked";
}
// src/app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { sql } from "@/lib/db";
import { stripe } from "@/lib/billing";
import { syncSubscriptionFromStripe } from "@/lib/entitlements";
export async function POST(request: NextRequest) {
const payload = await request.text();
const signature = request.headers.get("stripe-signature");
if (!signature) return NextResponse.json({ error: "Missing signature" }, { status: 400 });
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(payload, signature, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
const rows = await sql`
insert into webhook_events (event_id, event_type, status)
values (${event.id}, ${event.type}, 'processing')
on conflict (event_id) do nothing
returning event_id
`;
if (rows.length === 0) return NextResponse.json({ received: true, duplicate: true });
if (event.type === "checkout.session.completed") {
const session = event.data.object as Stripe.Checkout.Session;
const subscriptionId = typeof session.subscription === "string" ? session.subscription : session.subscription?.id;
if (subscriptionId) await syncSubscriptionFromStripe(subscriptionId);
}
if (event.type === "customer.subscription.created" || event.type === "customer.subscription.updated" || event.type === "customer.subscription.deleted") {
const subscription = event.data.object as Stripe.Subscription;
await syncSubscriptionFromStripe(subscription.id);
}
if (event.type === "invoice.paid" || event.type === "invoice.payment_failed") {
const invoice = event.data.object as Stripe.Invoice;
const value = invoice.parent?.subscription_details?.subscription as string | { id: string } | null | undefined;
const subscriptionId = typeof value === "string" ? value : value?.id;
if (subscriptionId) await syncSubscriptionFromStripe(subscriptionId);
}
await sql`update webhook_events set status = 'processed', processed_at = now() where event_id = ${event.id}`;
return NextResponse.json({ received: true });
}
Lokal testen
Kopiere den whsec_...-Wert aus stripe listen in STRIPE_WEBHOOK_SECRET.
stripe listen --events checkout.session.completed,customer.subscription.updated,customer.subscription.deleted,invoice.paid,invoice.payment_failed --forward-to localhost:3000/api/webhooks/stripe
stripe trigger checkout.session.completed
stripe trigger invoice.payment_failed
CLI-Trigger sind gut für Smoke Tests. Für die echte Prüfung solltest du im Testmodus Product und Price anlegen, Checkout im Browser abschließen und dann Customer Portal, Planwechsel und Kündigung testen.
Häufige Fehler
Gib bezahlte Features nicht nur frei, weil der Nutzer auf die Success-URL zurückkehrt. Zusätzliche Authentifizierung, spätere Webhooks oder abgebrochene Rückkehrwege sind normale Fälle.
Speichere userId nicht nur in der Checkout-Session. Wenn die Subscription später synchronisiert wird, gehört es auch in subscription_data.metadata.
Behandle past_due, unpaid und canceled nicht gleich. past_due kann eine kurze Kulanzphase haben; unpaid und canceled sollten in der Regel Bezahlrechte sperren.
Vergiss nicht die Customer-Portal-Konfiguration im Stripe Dashboard. Die API erstellt nur die Session. Welche Produkte, Planwechsel und Kündigungsoptionen verfügbar sind, entscheidest du dort.
Monetarisierung und nächster Schritt
In den ClaudeCodeLab-Tests für Inhalte und Templates war nicht der generierte Code der größte Hebel, sondern die feste Zustandstabelle. Sobald klar ist, welcher Stripe-Status welchen App-Zugriff bedeutet, werden Prompt, Review, Test, Analytics und Support konsistent.
Weiterführend: Stripe-Zahlungen mit Claude Code, Authentifizierung mit Claude Code und Analytics mit Claude Code. Wenn du Stripe Billing, Berechtigungstabellen und Claude-Code-Reviews für dein SaaS, Training oder Produkt-Funnel anpassen willst, starte mit Claude-Code-Training und Beratung oder der Produkt- und Template-Bibliothek.
Vor der Implementierung immer prüfen: Checkout Sessions API, Customer Portal, subscription webhooks, webhooks, subscription statuses und Claude Code setup.
Kostenloses PDF: Claude-Code-Cheatsheet
E-Mail eintragen und eine Seite mit Befehlen, Review-Gewohnheiten und sicheren Workflows herunterladen.
Wir schützen Ihre Daten und senden keinen Spam.
Über den Autor
Masa
Engineer für praktische Claude-Code-Workflows und Team-Einführung.
Ähnliche Artikel
Claude Code Workflow von Obsidian zu CLAUDE.md
Obsidian-Arbeitsnotizen in CLAUDE.md-Betriebsnotizen verwandeln und Kontext nicht ständig neu erklären.
Claude Code Revenue CTA Routing: Artikel zu PDF, Gumroad und Beratung führen
Ein Claude-Code-Ablauf, der Leser nach Absicht zu Gratis-PDF, Gumroad oder Beratung führt.
Claude-Code-Team-Handoff-Regeln: Belege, Berechtigungen, Rollback und Umsatzpfade
Ein praktisches Claude-Code-Handoff für Review-Belege, Berechtigungen, Rollback, Gratis-PDF, Gumroad und Beratung.