Use Cases (Diperbarui: 2/6/2026)

Membangun Langganan Stripe dengan Claude Code

Bangun langganan Stripe dengan Claude Code: Checkout, Portal, webhook, entitlement, dan monetisasi SaaS.

Membangun Langganan Stripe dengan Claude Code

Langganan adalah sistem pendapatan, bukan sekadar tombol bayar

Membuat Stripe Checkout Session untuk subscription cukup cepat. Namun produk nyata seperti SaaS, membership konten, pustaka template, atau paket konsultasi berulang membutuhkan lebih banyak hal: pembayaran gagal, perubahan paket, pembatalan di akhir periode, invoice, Customer Portal, retry Webhook, pencabutan akses, dan log yang bisa dipakai support.

Claude Code paling berguna ketika diberi konteks yang rapi. Jangan hanya meminta “integrasikan Stripe”. Berikan dokumentasi resmi, desain database, aturan status, perintah test lokal, dan kriteria review. Dengan begitu agent menghasilkan implementasi yang bisa diperiksa dan lebih dekat ke kebutuhan produksi.

Modelnya sederhana: Stripe adalah sumber kebenaran untuk billing. Database aplikasi menyimpan keputusan akses. Stripe tahu status active, trialing, past_due, unpaid, atau canceled; aplikasi memutuskan apakah user boleh download template, membaca konten premium, memakai team seats, atau mengirim permintaan review.

flowchart LR
  A["Pricing page"] --> B["Checkout Session<br/>mode=subscription"]
  B --> C["Stripe subscription"]
  C --> D["Signed webhook"]
  D --> E["billing_subscriptions"]
  D --> F["entitlements"]
  F --> G["Feature gate"]
  G --> H["SaaS, course, template library"]
  H --> I["Customer Portal"]
  I --> C

Mulai dari harga dan hak akses

Tentukan paket sebelum menulis kode. Ini mencegah logika monetisasi tersebar di komponen UI, route API, dan email support.

PaketTargetContoh bulananContoh tahunanAkses
Freepengunjung yang mencoba kontenRp0Rp0preview dan contoh download
Prodeveloper solo dan pembeli templateRp149.000Rp1.490.000artikel penuh, template, library kursus
Studiotim dan klien berulangRp799.000Rp7.990.000Pro, team seats, request review, materi training

Pakai minimal tiga use case konkret. Pertama, micro-SaaS yang membuka export, dashboard, dan automation hanya untuk paid plan. Kedua, funnel info-product dari artikel gratis ke template berbayar lalu membership. Ketiga, training atau konsultasi yang membutuhkan team seats, sesi review, invoice, dan reminder pembayaran. Use case seperti ini membuat status billing tidak tersembunyi di balik demo sederhana.

Entitlement berarti hak akses fitur, misalnya templates:download atau team:seats. Dunning adalah proses pemulihan setelah pembayaran gagal: email pengingat, retry, dan link untuk memperbarui metode pembayaran. Observability berarti kita bisa melacak event Stripe mana yang mengubah paket, status, dan akses user.

Prompt Claude Code dengan dokumentasi resmi

Stripe berubah dari waktu ke waktu, jadi arahkan Claude Code ke sumber resmi untuk klaim yang spesifik Stripe.

Implementasikan Stripe Billing subscriptions untuk aplikasi Next.js App Router.
Gunakan dokumentasi resmi ini sebagai acuan:
- Checkout Sessions API: https://docs.stripe.com/api/checkout/sessions/create
- Customer Portal: https://docs.stripe.com/customer-management
- Subscription webhooks: https://docs.stripe.com/billing/subscriptions/webhooks
- Webhooks and local testing: https://docs.stripe.com/webhooks
- Subscription object and statuses: https://docs.stripe.com/api/subscriptions/object

Syarat:
- TypeScript, Next.js App Router, Postgres
- Checkout memakai mode="subscription"
- Customer Portal menangani payment method, invoice, perubahan paket, dan pembatalan
- Webhook memverifikasi signature Stripe dan deduplicate berdasarkan event_id
- Tabel lokal entitlements menentukan akses aplikasi
- active/trialing memberi akses, past_due punya 3 hari grace, unpaid/canceled/paused mencabut akses
- Sertakan kode copy-paste dan perintah stripe listen

Schema database

billing_subscriptions menyimpan status terbaru dari Stripe. entitlements dibaca oleh aplikasi sebelum membuka fitur berbayar. webhook_events mencegah event yang sama diproses dua kali.

create table if not exists app_users (
  id text primary key,
  email text not null unique,
  stripe_customer_id text unique,
  created_at timestamptz not null default now()
);

create table if not exists billing_subscriptions (
  user_id text primary key references app_users(id) on delete cascade,
  stripe_customer_id text not null,
  stripe_subscription_id text not null unique,
  plan_key text not null,
  status text not null check (
    status in ('incomplete', 'incomplete_expired', 'trialing', 'active', 'past_due', 'canceled', 'unpaid', 'paused')
  ),
  access_state text not null check (access_state in ('pending', 'granted', 'grace', 'revoked')),
  current_period_end timestamptz,
  cancel_at_period_end boolean not null default false,
  grace_until timestamptz,
  updated_at timestamptz not null default now()
);

create table if not exists entitlements (
  user_id text not null references app_users(id) on delete cascade,
  feature_key text not null,
  active boolean not null default true,
  expires_at timestamptz,
  updated_at timestamptz not null default now(),
  primary key (user_id, feature_key)
);

create table if not exists webhook_events (
  event_id text primary key,
  event_type text not null,
  status text not null default 'processing',
  attempts integer not null default 1,
  processed_at timestamptz,
  last_error text,
  created_at timestamptz not null default now()
);

Checkout dan Customer Portal

Contoh memakai header x-demo-user-id agar mudah dicoba. Di production, ganti dengan sistem autentikasi aplikasi.

npm install stripe postgres
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRO_PRICE_ID=price_...
STRIPE_STUDIO_PRICE_ID=price_...
NEXT_PUBLIC_APP_URL=http://localhost:3000
DATABASE_URL=postgres://user:pass@localhost:5432/app
STRIPE_AUTOMATIC_TAX=false
// src/lib/db.ts
import postgres from "postgres";

export const sql = postgres(process.env.DATABASE_URL!, {
  max: 5,
  idle_timeout: 20,
});
// src/lib/billing.ts
import Stripe from "stripe";
import { sql } from "@/lib/db";

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";

export const PLANS = {
  free: { priceId: null, features: ["article:preview"] },
  pro: {
    priceId: process.env.STRIPE_PRO_PRICE_ID!,
    features: ["article:full", "templates:download", "course:library"],
  },
  studio: {
    priceId: process.env.STRIPE_STUDIO_PRICE_ID!,
    features: ["article:full", "templates:download", "course:library", "team:seats", "review:request"],
  },
} as const;

export type PlanKey = keyof typeof PLANS;
export type PaidPlanKey = Exclude<PlanKey, "free">;

export function isPaidPlanKey(value: unknown): value is PaidPlanKey {
  return value === "pro" || value === "studio";
}

export async function createCheckoutSession(userId: string, planKey: PaidPlanKey) {
  const customerId = await findOrCreateCustomer(userId);
  const plan = PLANS[planKey];

  const session = await stripe.checkout.sessions.create({
    customer: customerId,
    mode: "subscription",
    line_items: [{ price: plan.priceId, quantity: 1 }],
    client_reference_id: userId,
    allow_promotion_codes: true,
    automatic_tax: { enabled: process.env.STRIPE_AUTOMATIC_TAX === "true" },
    success_url: `${APP_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${APP_URL}/pricing`,
    subscription_data: { metadata: { userId, planKey } },
    metadata: { userId, planKey },
  });

  if (!session.url) throw new Error("Stripe did not return a Checkout URL");
  return session.url;
}

export async function createPortalSession(userId: string) {
  const customerId = await getCustomerId(userId);
  const session = await stripe.billingPortal.sessions.create({
    customer: customerId,
    return_url: `${APP_URL}/settings/billing`,
  });
  return session.url;
}

async function findOrCreateCustomer(userId: string) {
  const users = await sql`select id, email, stripe_customer_id from app_users where id = ${userId}`;
  const user = users[0] as { id: string; email: string; stripe_customer_id: string | null } | undefined;
  if (!user) throw new Error("User not found");
  if (user.stripe_customer_id) return user.stripe_customer_id;

  const customer = await stripe.customers.create({ email: user.email, metadata: { userId } });
  await sql`update app_users set stripe_customer_id = ${customer.id} where id = ${userId}`;
  return customer.id;
}

async function getCustomerId(userId: string) {
  const rows = await sql`select stripe_customer_id from app_users where id = ${userId}`;
  const customerId = rows[0]?.stripe_customer_id as string | undefined;
  if (!customerId) throw new Error("Stripe customer is not linked");
  return customerId;
}
// src/app/api/billing/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createCheckoutSession, isPaidPlanKey } from "@/lib/billing";

export async function POST(request: NextRequest) {
  const userId = request.headers.get("x-demo-user-id");
  if (!userId) return NextResponse.json({ error: "Missing x-demo-user-id" }, { status: 401 });

  const { planKey } = await request.json();
  if (!isPaidPlanKey(planKey)) return NextResponse.json({ error: "Invalid plan" }, { status: 400 });

  const url = await createCheckoutSession(userId, planKey);
  return NextResponse.json({ url });
}
// src/app/api/billing/portal/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createPortalSession } from "@/lib/billing";

export async function POST(request: NextRequest) {
  const userId = request.headers.get("x-demo-user-id");
  if (!userId) return NextResponse.json({ error: "Missing x-demo-user-id" }, { status: 401 });

  const url = await createPortalSession(userId);
  return NextResponse.json({ url });
}

Sinkronisasi entitlement lewat Webhook

Success URL bukan bukti pembayaran. Itu hanya alur UX. Akses premium harus dibuka dari Webhook yang signature-nya valid atau dari status terbaru yang dibaca dari API Stripe. Karena event bisa tiba tidak berurutan, ambil ulang subscription sebelum menulis ke database.

// src/lib/entitlements.ts
import Stripe from "stripe";
import { sql } from "@/lib/db";
import { PLANS, PlanKey, stripe } from "@/lib/billing";

type AccessState = "pending" | "granted" | "grace" | "revoked";

export async function syncSubscriptionFromStripe(subscriptionId: string) {
  const subscription = await stripe.subscriptions.retrieve(subscriptionId, { expand: ["items.data.price"] });
  const item = subscription.items.data[0];
  const planKey = planKeyFromPrice(item?.price.id);
  const userId = subscription.metadata.userId;
  if (!userId) throw new Error(`Subscription ${subscription.id} has no userId metadata`);

  const accessState = accessStateFor(subscription.status);
  const currentPeriodEnd = item?.current_period_end ? new Date(item.current_period_end * 1000) : null;
  const graceUntil = accessState === "grace" ? new Date(Date.now() + 3 * 24 * 60 * 60 * 1000) : null;

  await sql.begin(async (tx) => {
    await tx`
      insert into billing_subscriptions (
        user_id, stripe_customer_id, stripe_subscription_id, plan_key,
        status, access_state, current_period_end, cancel_at_period_end, grace_until, updated_at
      )
      values (
        ${userId}, ${subscription.customer as string}, ${subscription.id}, ${planKey},
        ${subscription.status}, ${accessState}, ${currentPeriodEnd},
        ${subscription.cancel_at_period_end}, ${graceUntil}, now()
      )
      on conflict (user_id) do update set
        stripe_customer_id = excluded.stripe_customer_id,
        stripe_subscription_id = excluded.stripe_subscription_id,
        plan_key = excluded.plan_key,
        status = excluded.status,
        access_state = excluded.access_state,
        current_period_end = excluded.current_period_end,
        cancel_at_period_end = excluded.cancel_at_period_end,
        grace_until = excluded.grace_until,
        updated_at = now()
    `;

    const features = accessState === "granted" || accessState === "grace" ? PLANS[planKey].features : PLANS.free.features;
    await tx`delete from entitlements where user_id = ${userId}`;
    for (const feature of features) {
      await tx`insert into entitlements (user_id, feature_key, active, expires_at, updated_at) values (${userId}, ${feature}, true, ${graceUntil}, now())`;
    }
  });

  console.info("stripe.subscription.synced", { userId, subscriptionId: subscription.id, status: subscription.status, accessState, planKey });
}

function planKeyFromPrice(priceId: string | undefined): PlanKey {
  const entry = Object.entries(PLANS).find(([, plan]) => plan.priceId === priceId);
  return entry ? (entry[0] as PlanKey) : "free";
}

function accessStateFor(status: Stripe.Subscription.Status): AccessState {
  if (status === "active" || status === "trialing") return "granted";
  if (status === "past_due") return "grace";
  if (status === "incomplete") return "pending";
  return "revoked";
}
// src/app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { sql } from "@/lib/db";
import { stripe } from "@/lib/billing";
import { syncSubscriptionFromStripe } from "@/lib/entitlements";

export async function POST(request: NextRequest) {
  const payload = await request.text();
  const signature = request.headers.get("stripe-signature");
  if (!signature) return NextResponse.json({ error: "Missing 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 });
  }

  const rows = await sql`
    insert into webhook_events (event_id, event_type, status)
    values (${event.id}, ${event.type}, 'processing')
    on conflict (event_id) do nothing
    returning event_id
  `;
  if (rows.length === 0) return NextResponse.json({ received: true, duplicate: true });

  if (event.type === "checkout.session.completed") {
    const session = event.data.object as Stripe.Checkout.Session;
    const subscriptionId = typeof session.subscription === "string" ? session.subscription : session.subscription?.id;
    if (subscriptionId) await syncSubscriptionFromStripe(subscriptionId);
  }

  if (event.type === "customer.subscription.created" || event.type === "customer.subscription.updated" || event.type === "customer.subscription.deleted") {
    const subscription = event.data.object as Stripe.Subscription;
    await syncSubscriptionFromStripe(subscription.id);
  }

  if (event.type === "invoice.paid" || event.type === "invoice.payment_failed") {
    const invoice = event.data.object as Stripe.Invoice;
    const value = invoice.parent?.subscription_details?.subscription as string | { id: string } | null | undefined;
    const subscriptionId = typeof value === "string" ? value : value?.id;
    if (subscriptionId) await syncSubscriptionFromStripe(subscriptionId);
  }

  await sql`update webhook_events set status = 'processed', processed_at = now() where event_id = ${event.id}`;
  return NextResponse.json({ received: true });
}

Test Webhook lokal

Salin nilai whsec_... dari output stripe listen ke STRIPE_WEBHOOK_SECRET.

stripe listen --events checkout.session.completed,customer.subscription.updated,customer.subscription.deleted,invoice.paid,invoice.payment_failed --forward-to localhost:3000/api/webhooks/stripe
stripe trigger checkout.session.completed
stripe trigger invoice.payment_failed

Trigger CLI berguna untuk smoke test. Validasi akhir sebaiknya memakai Product dan Price test mode, Checkout di browser, lalu Customer Portal untuk update kartu, perubahan paket, dan pembatalan.

Kesalahan yang sering mahal

Jangan buka fitur premium hanya karena user kembali ke success URL. User bisa tidak kembali, pembayaran bisa butuh autentikasi tambahan, dan Webhook bisa datang belakangan.

Jangan simpan userId hanya di metadata Checkout Session. Jika sinkronisasi memakai Subscription, tambahkan juga ke subscription_data.metadata.

Jangan samakan past_due, unpaid, dan canceled. past_due bisa diberi grace period singkat; unpaid dan canceled biasanya mencabut akses berbayar.

Jangan lupa konfigurasi Customer Portal di Stripe Dashboard. API membuat session, tetapi produk yang bisa dipilih, perubahan paket, metode pembayaran, dan pembatalan diatur di Dashboard.

Jangan menunda pajak, mata uang, refund, dan diskon tahunan sampai minggu launching. Stripe membantu otomatisasi, tetapi kebijakan bisnis tetap harus diputuskan manusia.

Monetisasi dan langkah berikutnya

Saat ClaudeCodeLab mencoba alur ini untuk konten dan template berbayar, manfaat terbesar bukan dari banyaknya kode yang dibuat, tetapi dari tabel status yang jelas sejak awal. Setelah active, past_due, unpaid, dan canceled punya arti akses yang tegas, prompt Claude Code, test, analytics, dan support message menjadi selaras.

Baca juga integrasi pembayaran Claude Code, implementasi autentikasi Claude Code, dan implementasi analytics Claude Code. Jika ingin menyesuaikan Stripe Billing, entitlement table, dan workflow review Claude Code untuk SaaS, course site, atau funnel produk, lihat training dan konsultasi Claude Code atau produk dan template.

Sebelum implementasi, cek ulang sumber resmi: Checkout Sessions API, Customer Portal, subscription webhooks, webhooks, subscription statuses, dan Claude Code setup.

#Claude Code #Stripe #langganan #SaaS #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.