Use Cases (Diperbarui: 1/6/2026)

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.

Implementasi Stripe Checkout dengan Claude Code: Sessions, Webhook, dan Idempotensi

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 caseCheckout modeFulfillmentRisiko
PDF atau template digitalpaymentMembuka akses downloadJangan hanya mengirim file dari success page
Paket SaaS ProsubscriptionMengaktifkan limit dan seatTangani subscription deletion
Deposit training atau konsultasipaymentMengunci slot jadwalTentukan 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.

#Claude Code #Stripe #pembayaran #Checkout #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.