Use Cases (Aktualisiert: 2.6.2026)

Stripe-Abonnements mit Claude Code implementieren

Stripe-Abos mit Claude Code umsetzen: Checkout, Portal, Webhooks, Berechtigungen und SaaS-Monetarisierung.

Stripe-Abonnements mit Claude Code implementieren

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.

PlanZielgruppeMonatlich BeispielJährlich BeispielBerechtigungen
FreeBesucher, die Inhalte testen0 €0 €Vorschau, Beispieldownloads
ProSolo-Entwickler, Template-Käufer19 €190 €Vollständige Artikel, Templates, Kursbibliothek
StudioTeams und wiederkehrende Kunden99 €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.

#Claude Code #Stripe #Abonnement #SaaS #TypeScript
Kostenlos

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.

Masa

Über den Autor

Masa

Engineer für praktische Claude-Code-Workflows und Team-Einführung.