Integrasi Pembayaran dengan Claude Code: Stripe Checkout, Webhook, Refund, dan Pajak
Bangun pembayaran Stripe dengan Claude Code: pricing, Checkout, Webhook, idempotency, fulfillment, refund, dan pajak.
Untuk SaaS kecil, produk konten berbayar, atau penjualan template, integrasi pembayaran bukan sekadar tombol “Beli”. Anda perlu tabel produk, API Checkout Session, verifikasi tanda tangan Webhook, fulfillment yang idempotent, logika refund, dan alur serah-terima ke pajak atau akuntansi. Tanpa itu, Stripe bisa mencatat pembayaran sukses sementara aplikasi belum memberi akses, atau refund sudah dibuat tetapi fitur berbayar masih aktif.
Claude Code bisa mempercepat pekerjaan ini, tetapi hanya jika batasannya jelas. Perintah seperti “tambahkan Stripe” sering menghasilkan UI yang tampak rapi tetapi logika success page yang rapuh. Untuk kode yang menyentuh revenue, pecah tugas menjadi bagian kecil: product table di server, Checkout route, verifikasi Webhook dengan raw body, deduplikasi event, fulfillment, refund, dan log yang tidak membocorkan data sensitif.
Panduan ini memakai Next.js App Router, Node.js, TypeScript, dan Stripe Checkout. Webhook adalah notifikasi server-to-server dari Stripe ke aplikasi Anda. Fulfillment berarti memberikan hal yang dibeli pelanggan: download, plan SaaS, kursi, booking, atau akses training. Idempotency berarti operasi yang sama bisa dijalankan dua kali tanpa menggandakan hasil.
Dokumentasi resmi yang perlu dibuka
Untuk detail khusus provider, gunakan dokumentasi resmi. Pembuatan session ada di Checkout Session create API, pengiriman produk setelah pembayaran di Checkout fulfillment, Webhook dan signature di Receive Stripe events, dan retry yang aman di Idempotent requests.
Refund ada di Create a refund, pajak di Stripe Tax for Checkout, dan testing lokal di Stripe CLI. Untuk Next.js, lihat Route Handlers. Untuk Claude Code, lihat Claude Code overview.
Jika nanti Anda pindah ke Paddle, Lemon Squeezy, PayPal, atau payment provider lokal, prinsipnya tetap sama: harga ditentukan server, pembayaran dikonfirmasi lewat Webhook, fulfillment harus idempotent, dan refund harus menjaga status akses aplikasi tetap konsisten.
Use case monetisasi
| Use case | Model | Fulfillment | Risiko nyata |
|---|---|---|---|
| Prompt pack, PDF, template bundle | Pembayaran sekali | Memberi download access dan email pembeli | Jika success page satu-satunya jalur, pelanggan yang menutup browser bisa kehilangan akses |
| Plan Pro SaaS kecil | Subscription bulanan | Mengaktifkan plan:pro dan memperbarui status dari event billing | Event cancel atau payment failed diabaikan, fitur berbayar tetap terbuka |
| Deposit workshop Claude Code | Pembayaran sekali atau invoice | Menahan slot dan memberi tahu tim operasional | Refund tidak otomatis menjelaskan apakah slot harus dilepas |
Dalam prototipe produk konten kecil, kesalahan pertama Masa bukan di Stripe API, tetapi menjadikan success page sebagai bukti pembayaran. Dengan test card semuanya terlihat sukses, tetapi saat dicek untuk Webhook retry, browser ditutup, delayed payment, dan refund, desain itu tidak aman. Instruksi yang lebih baik untuk Claude Code adalah: “success page hanya menampilkan status; akses diberikan lewat fulfillment function yang dipanggil Webhook.”
Arsitektur
flowchart LR
Buyer["Pembeli"] --> Pricing["Tabel harga"]
Pricing --> CheckoutApi["/api/checkout"]
CheckoutApi --> Order["Tabel Order"]
CheckoutApi --> Stripe["Stripe Checkout Session"]
Stripe --> Hosted["Stripe hosted checkout"]
Hosted --> Webhook["/api/webhooks/stripe"]
Webhook --> Fulfill["fulfillCheckoutSession"]
Fulfill --> Entitlement["Tabel Entitlement"]
Hosted --> Success["Success page"]
Success --> ReadOnly["Baca status order"]
Success page bukan tempat membuka akses. Halaman itu hanya menampilkan status. Webhook memverifikasi signature Stripe, menyimpan event ID, lalu memanggil satu fulfillment function yang aman dipanggil lebih dari sekali untuk Checkout Session yang sama.
Prompt yang bisa diberikan ke Claude Code:
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.
Product table dan environment
Browser hanya mengirim productKey. Price ID, mode pembayaran, dan entitlement key disimpan di 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;
}
Mulai dari test mode. Jangan tempel sk_test_, sk_live_, atau whsec_ ke prompt Claude Code.
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
Model database untuk idempotency
State pembayaran produksi harus ada di database. StripeEvent.id mencegah Webhook yang sama diproses dua kali. Entitlement adalah akses yang dicek aplikasi saat menampilkan fitur berbayar.
// 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 dan verifikasi Webhook harus berjalan di Node.js runtime. Route ini fokus pada autentikasi, membuat order internal, membuat Checkout Session, menyimpan Session ID, lalu mengembalikan URL.
// 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 dan tax_id_collection membantu mengumpulkan data pajak, tetapi bukan pengganti review akuntansi. Registrasi pajak, invoice, tarif, dan perlakuan refund tetap bergantung pada bisnis dan yurisdiksi.
Komponen pricing
UI tidak mengirim jumlah uang atau Price ID. Harga yang tampil adalah copy; harga final berasal dari Stripe Price.
// src/components/PricingTable.tsx
"use client";
import { useState } from "react";
const cards = [
{
productKey: "promptPack",
name: "Prompt Pack",
price: "Rp399.000",
description: "Pembayaran sekali untuk template dan materi belajar",
},
{
productKey: "proMonthly",
name: "Pro",
price: "Rp299.000/bulan",
description: "Plan bulanan untuk SaaS kecil",
},
{
productKey: "workshopDeposit",
name: "Workshop Deposit",
price: "Rp1.500.000",
description: "Deposit untuk training atau konsultasi",
},
];
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 ? "Mengalihkan..." : "Lanjut ke Checkout"}
</button>
</section>
))}
</div>
);
}
Menonaktifkan tombol hanya membantu UX. Keamanan pembayaran tetap dijaga oleh product table server-side, Stripe idempotency key, Webhook event ID, dan constraint database.
Verifikasi Webhook dan fulfillment
Jangan memanggil await req.json() sebelum verifikasi. Stripe membutuhkan raw body yang belum diubah.
// 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 };
});
}
Poin pentingnya adalah hanya ada satu tempat yang memberi akses berbayar. Webhook, repair job, atau status page bisa memanggil fungsi yang sama.
Refund dan pembalikan akses
Refund bukan hanya panggilan API Stripe. Produk harus menentukan apakah akses dicabut, partial refund tetap memberi akses, atau slot booking dilepas.
// 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 });
}
Minta Claude Code berhenti dan bertanya jika kebijakan refund belum jelas. “Partial refund tetap memberi akses” atau “full refund mencabut akses” adalah keputusan produk dan operasional.
Testing lokal
Gunakan Stripe CLI untuk meneruskan event ke route lokal.
npm run dev
stripe login
stripe listen --forward-to localhost:3000/api/webhooks/stripe
Salin whsec_... ke STRIPE_WEBHOOK_SECRET, restart Next.js, lalu jalankan Checkout di test mode. Kartu test 4242 4242 4242 4242, tanggal kedaluwarsa masa depan, CVC apa pun, dan kode pos apa pun cukup untuk success path dasar. Untuk decline dan autentikasi, lihat Testing.
Periksa empat hal: Checkout route membuat order internal, Webhook menerima checkout.session.completed, order menjadi FULFILLED dengan satu Entitlement, dan event yang sama tidak membuat akses kedua.
Prompt review untuk Claude Code
Setelah implementasi, pakai Claude Code sebagai reviewer.
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 bisa menulis kode, tetapi bukan pemilik akhir perilaku billing. API keys, Dashboard, pajak, terms, refund policy, dan privasi log tetap harus direview manusia.
Ringkasan dan CTA
Desain pembayaran dari revenue lifecycle, bukan dari tombol. Pricing table mengirim productKey, server membuat Checkout Session, Webhook memverifikasi signature, dan satu fulfillment function yang idempotent memberi akses. Refund dan tax handoff harus masuk desain pertama.
Untuk pembahasan Checkout yang lebih fokus, baca Stripe Checkout dengan Claude Code. Untuk rahasia dan env, lihat environment management, dan untuk event, lihat Webhook implementation.
ClaudeCodeLab membantu review payment implementation, training Claude Code, dan desain monetisasi SaaS atau produk konten. Jika belum ada halaman Indonesia, mulai dari English consulting page atau gunakan product library untuk template dan checklist.
Saya menguji flow ini di project Next.js lokal dengan Stripe test mode, Webhook forwarding, duplicate event, dan full refund. Perubahan paling penting adalah memodelkan Order dan Entitlement sebelum mempercantik UI. Setelah state model jelas, diff Claude Code dan review manusia jauh lebih mudah dipercaya.
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.