Use Cases (Diperbarui: 2/6/2026)

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.

Integrasi Pembayaran dengan Claude Code: Stripe Checkout, Webhook, 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 caseModelFulfillmentRisiko nyata
Prompt pack, PDF, template bundlePembayaran sekaliMemberi download access dan email pembeliJika success page satu-satunya jalur, pelanggan yang menutup browser bisa kehilangan akses
Plan Pro SaaS kecilSubscription bulananMengaktifkan plan:pro dan memperbarui status dari event billingEvent cancel atau payment failed diabaikan, fitur berbayar tetap terbuka
Deposit workshop Claude CodePembayaran sekali atau invoiceMenahan slot dan memberi tahu tim operasionalRefund 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.

#Claude Code #Stripe #pembayaran #Webhook #TypeScript
Gratis

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.

Masa

Tentang penulis

Masa

Engineer yang berfokus pada workflow Claude Code praktis dan adopsi tim.