Stripe Checkout mit Claude Code implementieren: Sessions, Webhooks und Idempotenz
Stripe Checkout mit Claude Code umsetzen: hosted Checkout, Webhooks, Metadata, Idempotenz, Testmodus und Recovery.
Stripe Checkout ist der schnellste Weg, kein eigenes Kreditkartenformular betreiben zu müssen. Eine produktionsreife Integration besteht aber aus mehr als einer Weiterleitungs-URL. Sie braucht die Checkout Sessions API, hosted Checkout, Webhook-Fulfillment, metadata.order_id, Idempotenz, Testmodus und klare Prüfungen für Rückerstattungen und Kündigungen.
Claude Code hilft dabei, wenn die Arbeit in kleine, prüfbare Schritte zerlegt wird. Bitten Sie nicht pauschal um “Stripe Payments”. Lassen Sie zuerst das Order-Schema erzeugen, dann die Umgebungsvariablen prüfen, danach die Checkout-Route, anschließend den Webhook mit Signaturprüfung und am Ende eine kritische Sicherheitsreview.
Das Beispiel nutzt Next.js App Router und Node TypeScript. Secret Keys stehen nicht im Quellcode. Die interne Bestellung wird vor der Stripe Checkout Session erstellt, und die eigentliche Leistung wird über eine idempotente Serverfunktion freigeschaltet.
Offizieller Ablauf
Die Checkout Sessions API ist die Basis für hosted Checkout, embedded Checkout und custom Flows. Für die meisten SaaS-, Kurs- und Digitalprodukt-Integrationen ist hosted Checkout der robuste Startpunkt, weil Stripe Zahlungsformular, Authentifizierungsschritte, Validierung und Fehlermeldungen übernimmt.
How Checkout works beschreibt den Lebenszyklus: Ihre App erstellt eine Checkout Session, der Kunde bezahlt auf der von Stripe gehosteten Seite, und ein Webhook-Ereignis wie checkout.session.completed stößt Fulfillment an. Fulfillment bedeutet, nach erfolgreicher Zahlung den gekauften Wert bereitzustellen: Download, SaaS-Zugang, Sitzplatz, Versand oder Termin.
Die offizielle Seite Fulfill orders betont, dass Webhooks Pflicht sind und Fulfillment mehrfach für dieselbe Session aufgerufen werden kann. Die Checkout Sessions API Reference zeigt außerdem, dass eine Session einen Zahlungsversuch repräsentiert und nach Erfolg Customer, PaymentIntent oder Subscription referenziert.
Für das Gesamtbild lesen Sie zusätzlich Claude Code Payment Integration, Claude Code Sicherheits-Best-Practices und Claude Code Environment Management.
Zielarchitektur
flowchart LR
Buyer["Käufer"] --> App["Next.js App"]
App --> CheckoutRoute["/api/checkout"]
CheckoutRoute --> Orders["Order-Tabelle"]
CheckoutRoute --> Stripe["Stripe Checkout Session"]
Stripe --> Hosted["Hosted Checkout"]
Hosted --> Webhook["/api/stripe/webhook"]
Webhook --> Fulfill["fulfillCheckout"]
Hosted --> Success["/checkout/success"]
Success --> Fulfill
Fulfill --> Orders
Die Checkout-Route erstellt oder verwendet eine interne Bestellung und gibt session.url zurück. Der Webhook liest den Raw Body, prüft die Stripe-Signatur und ruft Fulfillment auf. Die Erfolgsseite darf dieselbe Fulfillment-Funktion für bessere UX aufrufen, ersetzt aber niemals den Webhook.
Setup und Datenmodell
npm install stripe zod @prisma/client
npm install --save-dev prisma
npx prisma init
Lokal verwenden Sie nur Testwerte. Echte sk_test_, sk_live_ oder whsec_ Werte gehören nicht in Claude-Code-Prompts.
APP_URL=http://localhost:3000
DATABASE_URL=postgresql://user:password@localhost:5432/checkout_dev
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxx
STRIPE_PRICE_EBOOK=price_xxxxxxxxxxxxxxxxxxxxx
STRIPE_PRICE_PRO=price_xxxxxxxxxxxxxxxxxxxxx
// prisma/schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model Order {
id String @id @default(cuid())
cartId String @unique
userId String
email String?
productKey String
quantity Int @default(1)
status OrderStatus @default(PENDING)
stripeCheckoutSessionId String? @unique
stripePaymentIntentId String? @unique
stripeSubscriptionId String? @unique
amountTotal Int?
currency String?
fulfilledAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
logs FulfillmentLog[]
}
model FulfillmentLog {
id String @id @default(cuid())
orderId String
stripeCheckoutSessionId String
event String
createdAt DateTime @default(now())
order Order @relation(fields: [orderId], references: [id])
@@unique([orderId, event])
@@index([stripeCheckoutSessionId])
}
enum OrderStatus {
PENDING
PAID
FULFILLED
CANCELED
REFUNDED
FAILED
}
Server-Hilfsdateien
// src/lib/env.ts
import { z } from "zod";
const envSchema = z.object({
APP_URL: z.string().url(),
DATABASE_URL: z.string().min(1),
STRIPE_SECRET_KEY: z.string().regex(/^sk_(test|live)_/),
STRIPE_WEBHOOK_SECRET: z.string().regex(/^whsec_/),
STRIPE_PRICE_EBOOK: z.string().startsWith("price_"),
STRIPE_PRICE_PRO: z.string().startsWith("price_"),
});
export const env = envSchema.parse(process.env);
// src/lib/prisma.ts
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
// src/lib/stripe.ts
import Stripe from "stripe";
import { env } from "@/lib/env";
export const stripe = new Stripe(env.STRIPE_SECRET_KEY);
Checkout Session erstellen
// app/api/checkout/route.ts
import Stripe from "stripe";
import { NextResponse } from "next/server";
import { z } from "zod";
import { env } from "@/lib/env";
import { prisma } from "@/lib/prisma";
import { stripe } from "@/lib/stripe";
const checkoutRequestSchema = z.object({
cartId: z.string().min(8),
userId: z.string().min(1),
email: z.string().email().optional(),
productKey: z.enum(["ebook", "pro"]),
});
const priceByProduct = {
ebook: env.STRIPE_PRICE_EBOOK,
pro: env.STRIPE_PRICE_PRO,
} as const;
export async function POST(request: Request) {
const body = await request.json().catch(() => null);
const parsed = checkoutRequestSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid checkout request" }, { status: 400 });
}
const { cartId, userId, email, productKey } = parsed.data;
const mode: Stripe.Checkout.SessionCreateParams.Mode =
productKey === "pro" ? "subscription" : "payment";
const order = await prisma.order.upsert({
where: { cartId },
update: {},
create: { cartId, userId, email, productKey, quantity: 1, status: "PENDING" },
});
if (order.stripeCheckoutSessionId) {
const existing = await stripe.checkout.sessions.retrieve(order.stripeCheckoutSessionId);
if (existing.status === "open" && existing.url) {
return NextResponse.json({ url: existing.url, orderId: order.id });
}
return NextResponse.json(
{ error: "Checkout session is not open. Create a new cart." },
{ status: 409 },
);
}
const metadata = {
order_id: order.id,
cart_id: cartId,
product_key: productKey,
user_id: userId,
};
const session = await stripe.checkout.sessions.create(
{
mode,
line_items: [{ price: priceByProduct[productKey], quantity: 1 }],
customer_email: email,
success_url: `${env.APP_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${env.APP_URL}/pricing?checkout=cancelled&order_id=${order.id}`,
client_reference_id: order.id,
metadata,
allow_promotion_codes: true,
payment_intent_data: mode === "payment" ? { metadata } : undefined,
subscription_data: mode === "subscription" ? { metadata } : undefined,
},
{ idempotencyKey: `checkout:${order.id}:v1` },
);
await prisma.order.update({
where: { id: order.id },
data: { stripeCheckoutSessionId: session.id },
});
return NextResponse.json({ url: session.url, orderId: order.id });
}
Metadata enthält nur interne IDs. E-Mail-Adressen, Anschriften und Kartendaten gehören nicht hinein. idempotencyKey schützt die Session-Erstellung, aber nicht die doppelte Webhook-Verarbeitung.
Idempotentes Fulfillment und Webhook
// src/lib/checkout-fulfillment.ts
import Stripe from "stripe";
import { prisma } from "@/lib/prisma";
import { stripe } from "@/lib/stripe";
function stripeObjectId(
value: string | Stripe.PaymentIntent | Stripe.Subscription | null,
) {
if (!value) return null;
return typeof value === "string" ? value : value.id;
}
export async function fulfillCheckout(sessionId: string) {
const session = await stripe.checkout.sessions.retrieve(sessionId, {
expand: ["line_items", "payment_intent", "subscription"],
});
if (session.payment_status === "unpaid") {
return { status: "waiting_for_payment" as const, sessionId };
}
const orderId = session.metadata?.order_id ?? session.client_reference_id;
if (!orderId) throw new Error(`Checkout Session ${session.id} has no order_id`);
return prisma.$transaction(async (tx) => {
const updated = await tx.order.updateMany({
where: { id: orderId, fulfilledAt: null },
data: {
status: "FULFILLED",
stripeCheckoutSessionId: session.id,
stripePaymentIntentId: stripeObjectId(session.payment_intent),
stripeSubscriptionId: stripeObjectId(session.subscription),
amountTotal: session.amount_total,
currency: session.currency,
fulfilledAt: new Date(),
},
});
if (updated.count === 0) return { status: "already_fulfilled" as const, orderId };
await tx.fulfillmentLog.create({
data: { orderId, stripeCheckoutSessionId: session.id, event: "checkout.fulfilled" },
});
return { status: "fulfilled" as const, orderId };
});
}
// app/api/stripe/webhook/route.ts
import Stripe from "stripe";
import { NextResponse } from "next/server";
import { env } from "@/lib/env";
import { prisma } from "@/lib/prisma";
import { stripe } from "@/lib/stripe";
import { fulfillCheckout } from "@/lib/checkout-fulfillment";
export const runtime = "nodejs";
function idFromStripeObject(value: string | { id: string } | null | undefined) {
if (!value) return null;
return typeof value === "string" ? value : value.id;
}
async function markSession(sessionId: string, status: "CANCELED" | "FAILED") {
await prisma.order.updateMany({
where: { stripeCheckoutSessionId: sessionId, fulfilledAt: null },
data: { status },
});
}
export async function POST(request: Request) {
const signature = request.headers.get("stripe-signature");
if (!signature) return NextResponse.json({ error: "Missing signature" }, { status: 400 });
const rawBody = await request.text();
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(rawBody, signature, env.STRIPE_WEBHOOK_SECRET);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return NextResponse.json(
{ error: `Webhook signature verification failed: ${message}` },
{ status: 400 },
);
}
switch (event.type) {
case "checkout.session.completed":
case "checkout.session.async_payment_succeeded":
await fulfillCheckout((event.data.object as Stripe.Checkout.Session).id);
break;
case "checkout.session.async_payment_failed":
await markSession((event.data.object as Stripe.Checkout.Session).id, "FAILED");
break;
case "checkout.session.expired":
await markSession((event.data.object as Stripe.Checkout.Session).id, "CANCELED");
break;
case "charge.refunded": {
const charge = event.data.object as Stripe.Charge;
const paymentIntentId = idFromStripeObject(charge.payment_intent);
if (paymentIntentId) {
await prisma.order.updateMany({
where: { stripePaymentIntentId: paymentIntentId },
data: { status: "REFUNDED" },
});
}
break;
}
case "customer.subscription.deleted": {
const subscription = event.data.object as Stripe.Subscription;
await prisma.order.updateMany({
where: { stripeSubscriptionId: subscription.id },
data: { status: "CANCELED" },
});
break;
}
}
return NextResponse.json({ received: true });
}
Der Raw Body ist entscheidend. Wenn Claude Code vor der Signaturprüfung auf request.json() umstellt, ist das ein Sicherheitsfehler.
Tests, Praxisfälle und Fehler
stripe login
stripe listen --forward-to localhost:3000/api/stripe/webhook
Tragen Sie das von der CLI ausgegebene whsec_ als STRIPE_WEBHOOK_SECRET ein, starten Sie Next.js neu und zahlen Sie mit der Testkarte 4242 4242 4242 4242. Prüfen Sie danach nicht nur Stripe Dashboard, sondern auch die Datenbank: Order.status muss FULFILLED sein, Stripe-IDs müssen gespeichert sein und ein Reload der Erfolgsseite darf keinen zweiten Fulfillment-Log erzeugen.
| Use Case | Checkout mode | Fulfillment | Risiko |
|---|---|---|---|
| PDF oder Template | payment | Download-Zugriff freischalten | Nicht nur über die Erfolgsseite liefern |
| SaaS Pro Plan | subscription | Planlimits und Seats aktivieren | Kündigung und Zahlungsfehler behandeln |
| Workshop-Anzahlung | payment | Termin reservieren | Rückerstattungsregel vorher festlegen |
Typische Fehler sind konkret: Zugriff nur über success_url, fehlendes metadata.order_id, blindes Vertrauen in idempotencyKey, gemischte Test- und Live-Objekte, sowie Rückerstattung in Stripe ohne Anpassung der App-Berechtigungen.
Für Claude Code reicht ein enger Review-Auftrag.
Review only the Stripe Checkout implementation.
Check env-only secrets, metadata order_id without PII, raw-body webhook signature verification,
idempotent fulfillment, test-mode setup, refund and cancellation handling.
Return concrete file and line references.
Prüfpunkte vor dem echten Einsatz
Führen Sie vor dem Launch eine Testzahlung aus, beobachten Sie den Webhook, prüfen Sie die Order-Tabelle, laden Sie die Erfolgsseite neu, lassen Sie eine Session ablaufen, erstatten Sie eine Zahlung und kündigen Sie ein Testabo. Erst wenn dieselbe Checkout Session mehrfach verarbeitet werden kann, ohne Zugriff doppelt zu vergeben, ist die Basis für eine Produktionsreview solide.
ClaudeCodeLab unterstützt bei Reviews von Stripe Checkout, Schulungen für Claude-Code-Zahlungsworkflows und Implementierungen mit Ihren Order-, Refund- und Subscription-Regeln.
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.