Stripe-Zahlungen mit Claude Code: Checkout, Webhooks, Erstattungen und Steuern
Stripe mit Claude Code umsetzen: Preise, Checkout, Webhooks, Idempotenz, Fulfillment, Erstattungen und Steuern.
Für ein kleines SaaS, einen Kurs oder ein digitales Produkt bedeutet Zahlungsintegration mehr als einen Kaufbutton. Du brauchst eine Produkttabelle, eine API für Checkout Sessions, geprüfte Webhooks, idempotentes Fulfillment, Erstattungslogik und eine saubere Übergabe an Buchhaltung und Steuern. Sonst kann Stripe eine Zahlung anzeigen, während deine App keinen Zugriff freischaltet, oder eine Erstattung kann durchgeführt werden, während der bezahlte Plan aktiv bleibt.
Claude Code kann diese Arbeit beschleunigen, aber nur mit klaren Grenzen. Ein Prompt wie “baue Stripe ein” erzeugt oft eine brauchbare Oberfläche und eine riskante Logik auf der Erfolgsseite. Für Umsatzcode solltest du Claude Code in kleine, prüfbare Schritte aufteilen: serverseitige Preisentscheidung, Checkout-Route, Webhook-Verifikation mit raw body, Deduplizierung, Fulfillment, Erstattungen und Logs ohne sensible Daten.
Dieser Artikel nutzt Next.js App Router, Node.js, TypeScript und Stripe Checkout. Ein Webhook ist eine Server-zu-Server-Nachricht von Stripe an deine App. Fulfillment bedeutet, dem Kunden das Gekaufte zu geben: Download, SaaS-Plan, Sitzplatz, Buchung oder Schulungszugang. Idempotenz bedeutet, dass derselbe Vorgang zweimal laufen kann, ohne das Ergebnis zu verdoppeln.
Offizielle Dokumentation
Provider-spezifische Details gehören in die offiziellen Quellen. Die Erstellung einer Session steht in der Checkout Session create API, die Auslieferung nach Zahlung in Checkout fulfillment, Webhooks und Signaturen in Receive Stripe events, und sichere Wiederholungen in Idempotent requests.
Erstattungen findest du in Create a refund, Steuerthemen in Stripe Tax for Checkout, lokale Tests in Stripe CLI. Für Next.js nutze Route Handlers. Für Claude Code selbst siehe Claude Code overview.
Wenn du später Paddle, Lemon Squeezy, PayPal oder einen lokalen Anbieter verwendest, bleibt die Architektur gleich: Der Server bestimmt den Preis, die Zahlung wird per Webhook bestätigt, Fulfillment ist idempotent, und Erstattungen halten den App-Zugriff konsistent.
Monetarisierungsfälle
| Fall | Abrechnung | Fulfillment | Typischer Fehler |
|---|---|---|---|
| Prompt-Paket, PDF-Kurs, Template-Bundle | Einmalzahlung | Download freischalten und Käufermail senden | Nur die Erfolgsseite liefert den Link, und ein geschlossener Browser verliert den Zugang |
| Kleines SaaS im Pro-Plan | Monatliches Abo | plan:pro aktivieren und spätere Billing-Events auswerten | Kündigungen oder fehlgeschlagene Rechnungen werden ignoriert |
| Anzahlung für Claude-Code-Workshop | Einmalzahlung oder Rechnung | Termin reservieren und Team informieren | Bei Erstattung ist unklar, ob der Termin wieder freigegeben wird |
In einem kleinen Content-Prototyp war Masas erster Fehler nicht die Stripe-API, sondern die Erfolgsseite als Wahrheitsquelle. Mit einer Testkarte lief es, aber Webhook-Retries, geschlossene Browser, verzögerte Zahlungen und Erstattungen machten das Design unsicher. Die bessere Anweisung an Claude Code war: “Die Erfolgsseite zeigt nur Status. Zugriff wird ausschließlich über eine gemeinsame Fulfillment-Funktion gewährt.”
Architektur
flowchart LR
Buyer["Käufer"] --> Pricing["Preistabelle"]
Pricing --> CheckoutApi["/api/checkout"]
CheckoutApi --> Order["Order-Tabelle"]
CheckoutApi --> Stripe["Stripe Checkout Session"]
Stripe --> Hosted["Stripe hosted checkout"]
Hosted --> Webhook["/api/webhooks/stripe"]
Webhook --> Fulfill["fulfillCheckoutSession"]
Fulfill --> Entitlement["Entitlement-Tabelle"]
Hosted --> Success["Erfolgsseite"]
Success --> ReadOnly["Bestellstatus lesen"]
Die Erfolgsseite gewährt keinen Zugriff. Sie zeigt nur den Status. Der Webhook prüft die Stripe-Signatur, speichert die Event-ID und ruft eine einzige Fulfillment-Funktion auf, die dieselbe Checkout Session mehrfach verarbeiten kann, ohne Rechte zu duplizieren.
Ein guter Claude-Code-Prompt:
Implement Stripe Checkout in a Next.js App Router app.
Do not grant access from the success page alone.
Verify the Stripe webhook with the raw request body.
Make fulfillment safe if the same Checkout Session is processed twice.
Do not print secrets or read .env values into chat.
Before editing, explain the target files and design.
Produkttabelle und Umgebung
Der Browser sendet nur productKey. Price IDs, Zahlungsmodus und Berechtigungsschlüssel bleiben auf dem Server.
// src/lib/billing/catalog.ts
export const BILLING_PRODUCTS = {
promptPack: {
label: "Claude Code Prompt Pack",
mode: "payment",
entitlement: "download:prompt-pack",
priceEnv: "STRIPE_PRICE_PROMPT_PACK",
},
proMonthly: {
label: "SaaS Pro Monthly",
mode: "subscription",
entitlement: "plan:pro",
priceEnv: "STRIPE_PRICE_PRO_MONTHLY",
},
workshopDeposit: {
label: "Claude Code Workshop Deposit",
mode: "payment",
entitlement: "booking:workshop-deposit",
priceEnv: "STRIPE_PRICE_WORKSHOP_DEPOSIT",
},
} as const;
export type ProductKey = keyof typeof BILLING_PRODUCTS;
export function getBillingProduct(productKey: string) {
if (productKey in BILLING_PRODUCTS) {
return {
key: productKey as ProductKey,
...BILLING_PRODUCTS[productKey as ProductKey],
};
}
throw new Error(`Unknown productKey: ${productKey}`);
}
export function getPriceId(priceEnv: string) {
const priceId = process.env[priceEnv];
if (!priceId) throw new Error(`Missing environment variable: ${priceEnv}`);
return priceId;
}
Starte lokal im Testmodus. Füge keine sk_test_, sk_live_ oder whsec_ Werte in Claude-Code-Prompts ein.
APP_URL=http://localhost:3000
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxx
STRIPE_PRICE_PROMPT_PACK=price_xxxxxxxxxxxxxxxxx
STRIPE_PRICE_PRO_MONTHLY=price_xxxxxxxxxxxxxxxxx
STRIPE_PRICE_WORKSHOP_DEPOSIT=price_xxxxxxxxxxxxxxxxx
Datenmodell für Idempotenz
Produktionszustand gehört in die Datenbank. StripeEvent.id verhindert doppelte Webhook-Verarbeitung. Entitlement ist der Zugriff, den deine App tatsächlich prüft.
// prisma/schema.prisma
model Order {
id String @id @default(cuid())
userId String
productKey String
entitlement String
status String @default("PENDING")
stripeCheckoutSessionId String? @unique
stripePaymentIntentId String?
stripeSubscriptionId String?
stripeRefundId String?
fulfilledAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Entitlement {
id String @id @default(cuid())
userId String
key String
active Boolean @default(true)
source String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, key])
}
model StripeEvent {
id String @id
type String
createdAt DateTime @default(now())
}
npx prisma db push
Checkout API
Stripe SDK und Webhook-Verifikation laufen im Node.js-Runtime. Die Route authentifiziert, erstellt eine interne Order, erstellt die Checkout Session, speichert die Session-ID und gibt die URL zurück.
// src/lib/billing/stripe.ts
import Stripe from "stripe";
if (!process.env.STRIPE_SECRET_KEY) {
throw new Error("STRIPE_SECRET_KEY is required");
}
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export function appUrl(path: string) {
const baseUrl = process.env.APP_URL ?? "http://localhost:3000";
return `${baseUrl.replace(/\/$/, "")}${path}`;
}
// src/app/api/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getBillingProduct, getPriceId } from "@/lib/billing/catalog";
import { appUrl, stripe } from "@/lib/billing/stripe";
import { requireUser } from "@/lib/auth";
export const runtime = "nodejs";
const checkoutSchema = z.object({
productKey: z.string().min(1),
});
export async function POST(req: NextRequest) {
const user = await requireUser();
const { productKey } = checkoutSchema.parse(await req.json());
const product = getBillingProduct(productKey);
const order = await prisma.order.create({
data: {
userId: user.id,
productKey: product.key,
entitlement: product.entitlement,
},
});
const session = await stripe.checkout.sessions.create(
{
mode: product.mode,
customer_email: user.email,
line_items: [{ price: getPriceId(product.priceEnv), quantity: 1 }],
client_reference_id: order.id,
metadata: {
order_id: order.id,
product_key: product.key,
},
automatic_tax: { enabled: true },
tax_id_collection: { enabled: true },
success_url: appUrl("/checkout/success?session_id={CHECKOUT_SESSION_ID}"),
cancel_url: appUrl("/pricing"),
},
{
idempotencyKey: `checkout:${order.id}`,
}
);
await prisma.order.update({
where: { id: order.id },
data: { stripeCheckoutSessionId: session.id },
});
if (!session.url) {
return NextResponse.json({ error: "Checkout URL was not created" }, { status: 500 });
}
return NextResponse.json({ url: session.url });
}
automatic_tax und tax_id_collection erleichtern die Steuerdatenerfassung, ersetzen aber keine Buchhaltungsprüfung. Registrierung, Rechnungstexte, Steuersätze und Erstattungsbehandlung hängen vom Geschäft ab.
Preistabelle
Die UI sendet weder Beträge noch Price IDs. Angezeigte Preise sind Copy; der echte Preis kommt aus Stripe.
// src/components/PricingTable.tsx
"use client";
import { useState } from "react";
const cards = [
{
productKey: "promptPack",
name: "Prompt Pack",
price: "39 €",
description: "Einmalzahlung für Templates und Lernmaterial",
},
{
productKey: "proMonthly",
name: "Pro",
price: "29 €/Monat",
description: "Monatsplan für ein kleines SaaS",
},
{
productKey: "workshopDeposit",
name: "Workshop Deposit",
price: "100 €",
description: "Anzahlung für Training oder Beratung",
},
];
export function PricingTable() {
const [loadingKey, setLoadingKey] = useState<string | null>(null);
async function startCheckout(productKey: string) {
setLoadingKey(productKey);
const res = await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ productKey }),
});
const data = await res.json();
if (!res.ok) {
setLoadingKey(null);
alert(data.error ?? "Checkout failed");
return;
}
window.location.assign(data.url);
}
return (
<div className="grid gap-4 md:grid-cols-3">
{cards.map((card) => (
<section key={card.productKey} className="rounded-lg border p-5">
<h3 className="text-lg font-semibold">{card.name}</h3>
<p className="mt-2 text-2xl font-bold">{card.price}</p>
<p className="mt-2 text-sm text-gray-600">{card.description}</p>
<button
className="mt-4 rounded bg-black px-4 py-2 text-white disabled:opacity-60"
disabled={loadingKey === card.productKey}
onClick={() => startCheckout(card.productKey)}
>
{loadingKey === card.productKey ? "Weiterleitung..." : "Weiter zu Checkout"}
</button>
</section>
))}
</div>
);
}
Der deaktivierte Button verhindert nervige Doppelklicks, ist aber nicht die Hauptsicherung. Die Sicherung liegt in serverseitiger Produkttabelle, Stripe idempotency key, eindeutiger Event-ID und Datenbank-Constraints.
Webhook-Verifikation und Fulfillment
Rufe nicht await req.json() auf, bevor du die Signatur prüfst. Stripe benötigt den unveränderten raw body.
// src/app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { prisma } from "@/lib/db";
import { stripe } from "@/lib/billing/stripe";
import { fulfillCheckoutSession } from "@/lib/billing/fulfillment";
export const runtime = "nodejs";
export async function POST(req: NextRequest) {
const payload = await req.text();
const signature = req.headers.get("stripe-signature");
if (!signature || !process.env.STRIPE_WEBHOOK_SECRET) {
return NextResponse.json({ error: "Missing webhook 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 });
}
try {
await prisma.stripeEvent.create({ data: { id: event.id, type: event.type } });
} catch {
return NextResponse.json({ received: true, duplicate: true });
}
if (
event.type === "checkout.session.completed" ||
event.type === "checkout.session.async_payment_succeeded"
) {
const session = event.data.object as Stripe.Checkout.Session;
await fulfillCheckoutSession(session.id);
}
if (event.type === "checkout.session.async_payment_failed") {
const session = event.data.object as Stripe.Checkout.Session;
await prisma.order.updateMany({
where: { stripeCheckoutSessionId: session.id, status: "PENDING" },
data: { status: "CANCELED" },
});
}
return NextResponse.json({ received: true });
}
// src/lib/billing/fulfillment.ts
import { prisma } from "@/lib/db";
import { stripe } from "@/lib/billing/stripe";
function stripeObjectId(value: string | { id: string } | null | undefined) {
if (!value) return undefined;
return typeof value === "string" ? value : value.id;
}
export async function fulfillCheckoutSession(sessionId: string) {
const session = await stripe.checkout.sessions.retrieve(sessionId, {
expand: ["payment_intent", "subscription"],
});
if (session.payment_status !== "paid") {
return { status: "waiting_for_payment" as const };
}
const orderId = session.metadata?.order_id;
if (!orderId) throw new Error(`Missing order_id metadata for ${session.id}`);
return prisma.$transaction(async (tx) => {
const order = await tx.order.findUnique({ where: { id: orderId } });
if (!order) throw new Error(`Order not found: ${orderId}`);
if (order.status === "FULFILLED") {
return { status: "already_fulfilled" as const, order };
}
const updatedOrder = await tx.order.update({
where: { id: order.id },
data: {
status: "FULFILLED",
stripeCheckoutSessionId: session.id,
stripePaymentIntentId: stripeObjectId(session.payment_intent),
stripeSubscriptionId: stripeObjectId(session.subscription),
fulfilledAt: new Date(),
},
});
await tx.entitlement.upsert({
where: {
userId_key: {
userId: order.userId,
key: order.entitlement,
},
},
update: { active: true, source: `stripe:${session.id}` },
create: {
userId: order.userId,
key: order.entitlement,
active: true,
source: `stripe:${session.id}`,
},
});
return { status: "fulfilled" as const, order: updatedOrder };
});
}
Wichtig ist ein einziger Ort für Zugriffserteilung. Webhook, Reparaturjob oder Statusseite können dieselbe Funktion nutzen.
Erstattungen und Zugriff
Eine Erstattung ist mehr als ein Stripe-Aufruf. Dein Produkt muss entscheiden, ob Zugriff entzogen wird, ob Teil-Erstattungen Zugriff behalten, oder ob ein Termin wieder frei wird.
// src/app/api/admin/refunds/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { stripe } from "@/lib/billing/stripe";
import { requireAdmin } from "@/lib/auth";
export const runtime = "nodejs";
const refundSchema = z.object({
orderId: z.string().min(1),
amount: z.number().int().positive().optional(),
});
export async function POST(req: NextRequest) {
await requireAdmin();
const { orderId, amount } = refundSchema.parse(await req.json());
const order = await prisma.order.findUnique({ where: { id: orderId } });
if (!order?.stripePaymentIntentId) {
return NextResponse.json({ error: "Refundable payment not found" }, { status: 404 });
}
const refund = await stripe.refunds.create(
{
payment_intent: order.stripePaymentIntentId,
amount,
metadata: { order_id: order.id },
},
{
idempotencyKey: `refund:${order.id}:${amount ?? "full"}`,
}
);
await prisma.$transaction([
prisma.order.update({
where: { id: order.id },
data: { status: "REFUNDED", stripeRefundId: refund.id },
}),
prisma.entitlement.updateMany({
where: { userId: order.userId, key: order.entitlement },
data: { active: false },
}),
]);
return NextResponse.json({ refundId: refund.id, status: refund.status });
}
Claude Code sollte nachfragen, bevor es Erstattungsregeln erfindet. Teil-Erstattung mit Zugriff oder volle Erstattung mit Entzug ist eine Produktentscheidung.
Lokale Tests
Mit Stripe CLI leitest du Events an deine lokale Route weiter.
npm run dev
stripe login
stripe listen --forward-to localhost:3000/api/webhooks/stripe
Kopiere das angezeigte whsec_... in STRIPE_WEBHOOK_SECRET, starte Next.js neu und teste Checkout. Die Testkarte 4242 4242 4242 4242, ein zukünftiges Ablaufdatum, beliebiger CVC und beliebige Postleitzahl decken den einfachen Erfolgspfad ab. Ablehnungen und Authentifizierung findest du unter Testing.
Prüfe vier Dinge: Die Checkout-Route erstellt eine interne Order, der Webhook erhält checkout.session.completed, die Order wird FULFILLED und erzeugt nur ein Entitlement, und dasselbe Event erzeugt keinen zweiten Zugriff.
Review-Prompt für Claude Code
Nach der Implementierung lässt du Claude Code reviewen.
Review this payment diff critically.
Check these risks:
- Access is not granted from the success page alone.
- The webhook verifies the raw body before JSON parsing.
- Stripe Event ID and Checkout Session ID prevent duplicate processing.
- The browser cannot choose price IDs or amounts.
- Metadata does not contain email, address, card data, or other sensitive data.
- Refunds keep Order and Entitlement state consistent.
- Tax, receipts, and cancellation policy are handed off to operations.
Claude Code kann Code schreiben, aber nicht die Verantwortung für Billing übernehmen. API-Schlüssel, Dashboard, Steuerregistrierung, AGB, Erstattungsregeln und Datenschutz in Logs bleiben menschliche Prüfung.
Fazit und CTA
Zahlungen werden vom Revenue-Lifecycle her designt, nicht vom Button. Die Preistabelle sendet productKey, der Server erstellt Checkout, der Webhook prüft die Signatur, und eine idempotente Fulfillment-Funktion erteilt Zugriff. Erstattung und Steuerübergabe gehören in das erste Design.
Für einen engeren Checkout-Guide lies Stripe Checkout mit Claude Code. Für Secrets siehe Umgebungsvariablen mit Claude Code, für Events Webhook-Implementierung mit Claude Code.
ClaudeCodeLab unterstützt Payment-Reviews, Claude-Code-Schulungen und Monetarisierungsdesign für SaaS und Content-Produkte. Starte über die englische Consulting-Seite oder nutze die Product Library mit Templates und Checklisten.
Ich habe diesen Flow lokal in Next.js mit Stripe-Testmodus, Webhook-Forwarding, doppeltem Event und vollständiger Erstattung geprüft. Der größte Gewinn war, Order und Entitlement vor der UI zu modellieren. Mit klarem Statusmodell werden Claude Codes Diff und der menschliche Review deutlich belastbarer.
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.