Implementasi Stripe Checkout dengan Claude Code: Sessions, Webhook, dan Idempotensi
Bangun Stripe Checkout dengan Claude Code: hosted checkout, webhook, metadata, idempotensi, mode test, dan recovery.
Stripe Checkout membuat tim tidak perlu membangun form kartu sendiri, tetapi bukan berarti semua logika pembayaran selesai otomatis. Integrasi yang layak produksi tetap membutuhkan Checkout Sessions API, hosted checkout, webhook fulfillment, metadata.order_id, idempotensi, mode test, serta pemeriksaan refund dan cancellation.
Claude Code berguna jika tugasnya dipecah kecil. Jangan meminta “buat pembayaran Stripe” sekaligus. Minta schema order terlebih dahulu, lalu validasi environment variable, route Checkout, webhook dengan signature verification, fungsi fulfillment yang idempotent, dan terakhir review keamanan.
Artikel ini memakai Next.js App Router dan Node TypeScript. Kode tidak menaruh secret key di source, membuat order internal sebelum Stripe Checkout Session, lalu memberikan akses melalui fungsi server yang aman dipanggil lebih dari sekali.
Alur resmi Stripe
Checkout Sessions API adalah API utama untuk hosted Checkout, embedded Checkout, dan custom flow. Untuk kebanyakan produk digital, SaaS, dan pelatihan, hosted checkout adalah awal yang paling aman karena Stripe menangani form pembayaran, autentikasi, validasi, dan pesan error.
Di How Checkout works, alurnya sederhana: aplikasi membuat Checkout Session, pelanggan membayar di halaman yang di-host Stripe, lalu event webhook seperti checkout.session.completed memicu fulfillment. Fulfillment berarti memberikan sesuatu yang dibeli pelanggan, misalnya akses download, paket SaaS, kursi workshop, pengiriman, atau konfirmasi booking.
Panduan Fulfill orders menekankan bahwa webhook wajib dipakai dan fungsi fulfillment harus aman meskipun dipanggil beberapa kali untuk Checkout Session yang sama. Checkout Sessions API reference juga menjelaskan bahwa Session mewakili satu percobaan pembayaran dan setelah sukses akan mengacu ke Customer, PaymentIntent, atau Subscription.
Untuk konteks yang lebih luas, baca juga integrasi pembayaran Claude Code, praktik keamanan Claude Code, dan manajemen environment Claude Code.
Arsitektur
flowchart LR
Buyer["Pembeli"] --> App["Aplikasi Next.js"]
App --> CheckoutRoute["/api/checkout"]
CheckoutRoute --> Orders["Tabel Order"]
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
Route Checkout hanya membuat atau memakai ulang order internal dan mengembalikan session.url. Webhook membaca raw body, memverifikasi signature Stripe, lalu memanggil fulfillment. Success page boleh memanggil fungsi yang sama untuk pengalaman pengguna yang lebih cepat, tetapi tidak boleh menggantikan webhook.
Environment dan database
npm install stripe zod @prisma/client
npm install --save-dev prisma
npx prisma init
Gunakan nilai test mode di lokal. Jangan tempel nilai asli sk_test_, sk_live_, atau whsec_ ke prompt Claude Code.
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
Order internal dibuat sebelum Checkout Session supaya client_reference_id dan metadata.order_id selalu membawa ID bisnis yang stabil.
// 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
}
Utilitas server
// 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);
Membuat Checkout Session
// 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 hanya berisi ID internal. Email, alamat, dan data kartu tidak boleh dimasukkan. idempotencyKey membantu retry saat membuat Session, tetapi webhook fulfillment tetap harus dilindungi oleh database.
Fulfillment idempotent dan 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 });
}
Raw body wajib untuk signature verification. Jika Claude Code mengubahnya menjadi request.json() sebelum verifikasi, itu bug keamanan.
Test mode, use case, dan kesalahan umum
stripe login
stripe listen --forward-to localhost:3000/api/stripe/webhook
Masukkan nilai whsec_ dari Stripe CLI ke STRIPE_WEBHOOK_SECRET, restart Next.js, lalu bayar dengan kartu test 4242 4242 4242 4242. Periksa database, bukan hanya Stripe Dashboard: Order.status harus menjadi FULFILLED, ID Stripe tersimpan, dan refresh success page tidak membuat log fulfillment kedua.
| Use case | Checkout mode | Fulfillment | Risiko |
|---|---|---|---|
| PDF atau template digital | payment | Membuka akses download | Jangan hanya mengirim file dari success page |
| Paket SaaS Pro | subscription | Mengaktifkan limit dan seat | Tangani subscription deletion |
| Deposit training atau konsultasi | payment | Mengunci slot jadwal | Tentukan efek refund terhadap slot |
Kesalahan yang sering terjadi: akses diberikan hanya di success_url, lupa metadata.order_id, menganggap Stripe idempotencyKey cukup untuk webhook, mencampur test key dengan live price, dan melakukan refund di Stripe tanpa mengubah permission di aplikasi.
Gunakan prompt review yang sempit untuk Claude Code.
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.
Poin konfirmasi saat mencoba
Sebelum rilis, jalankan pembayaran test mode, lihat log webhook, cek tabel order, refresh success page, expire satu Session, refund satu charge, dan cancel subscription test. Jika Checkout Session yang sama dapat diproses berkali-kali tanpa membuka akses dua kali, fondasi untuk review produksi sudah kuat.
ClaudeCodeLab dapat membantu review implementasi Stripe Checkout, melatih tim memakai Claude Code untuk workflow pembayaran, atau membangun versi pertama sesuai aturan order, refund, dan subscription di produk Anda.
PDF gratis: cheatsheet Claude Code
Masukkan email dan unduh satu halaman berisi command, kebiasaan review, dan workflow aman.
Kami menjaga datamu dan tidak mengirim spam.
Tentang penulis
Masa
Engineer yang berfokus pada workflow Claude Code praktis dan adopsi tim.
Artikel terkait
Workflow Obsidian ke CLAUDE.md untuk Claude Code
Ubah catatan kerja Obsidian menjadi operating note CLAUDE.md agar konteks tidak dijelaskan ulang.
Claude Code Revenue CTA Routing: dari artikel ke PDF, Gumroad, dan konsultasi
Workflow Claude Code untuk mengarahkan pembaca ke PDF gratis, Gumroad, atau konsultasi sesuai intent.
Aturan handoff tim Claude Code: bukti review, permission, rollback, dan jalur revenue
Format handoff Claude Code untuk tim: bukti, permission rule, rollback, PDF gratis, Gumroad, dan konsultasi.