Use Cases (更新: 2026/6/2)

Claude CodeでStripe決済を実装する実践ガイド:Checkout・Webhook・返金まで

Claude Codeで小さなSaaSのStripe決済を実装。料金表、Checkout、Webhook、冪等性、返金と税務引き継ぎまで解説。

Claude CodeでStripe決済を実装する実践ガイド:Checkout・Webhook・返金まで

小さなSaaSや教材販売サイトで収益化を始めるとき、最初の壁は「決済ボタンを置くこと」ではありません。料金表、Checkout Session、Webhook、権限付与、返金、税務資料への引き継ぎまでを一つの流れにしないと、売上は立ったのにユーザーには商品が渡らない、返金したのにアプリの権限が残る、という事故が起きます。

Claude Codeはこの作業をかなり短縮できます。ただし、「Stripe決済を作って」と丸投げすると、成功ページだけで購入済みにする危険な実装や、Webhook署名検証を飛ばしたコードが出ることがあります。決済は売上・個人情報・法務に触れるので、Claude Codeには実装者だけでなく、設計を確認するレビュー担当として働かせるのが現実的です。

この記事では、Next.js App Router、Node.js、TypeScript、Stripe Checkoutを前提に、小さなSaaS・コンテンツ商品・研修予約金に使える実装を示します。Webhookは「Stripeから自分のサーバーに届く決済イベント通知」、fulfillmentは「支払い後に教材、SaaS権限、予約枠などを渡す処理」、冪等性は「同じ処理が2回届いても結果を1回分に保つ性質」です。専門用語はこの3つだけ先に押さえれば読み進められます。

公式ドキュメントで確認する範囲

Stripeの仕様は変わるため、Provider固有の判断は必ず公式ドキュメントで確認します。Checkout Sessionを作るパラメータはCheckout Sessions API、支払い後の商品提供はCheckout fulfillment、Webhookの受信と署名検証はReceive Stripe events、再試行の安全性はIdempotent requestsを見ます。

返金はCreate a refund、税金はStripe Tax for Checkout、ローカル検証はStripe CLIが一次情報です。Next.js側のAPI実装はRoute Handlers、Claude Code自体はClaude Code overviewを参照してください。

この記事はStripeを例にしますが、Paddle、Lemon Squeezy、PayPalなどに置き換える場合も考え方は同じです。価格はサーバーで決める、決済完了はWebhookで確定する、同じイベントを2回処理しても壊れない、返金と権限を一緒に扱う。この4点をClaude Codeに守らせます。

3つの収益化ユースケース

ユースケース課金形態fulfillment落とし穴
プロンプト集やPDF教材の販売一回払いダウンロード権限を付与し、購入者メールに案内を送る成功ページだけにダウンロードURLを出すと、ブラウザを閉じた購入者を救えない
小さなSaaSのProプラン月額サブスクリプションplan:pro 権限を付与し、失敗請求や解約で状態を更新する解約Webhookを無視すると、退会後も有料機能が残る
Claude Code研修や個別相談の予約金一回払いまたは請求書運用予約枠を仮確定し、運営者に通知する返金時に予約枠を戻すか、キャンセルポリシーを人手で確認するかが曖昧になりやすい

Masaが検証した小さな教材販売の試作では、最初に失敗したのはStripe APIではなく「成功ページで購入済みにする」設計でした。テストカードでは通りますが、Webhook再送、ブラウザ終了、非同期決済、返金を考えると破綻します。Claude Codeへの指示を「成功ページは確認表示、権限付与はWebhookと共通のfulfillment関数」に変えたところ、レビューしやすい構成になりました。

全体アーキテクチャ

flowchart LR
  Buyer["購入者"] --> Pricing["料金表"]
  Pricing --> CheckoutApi["/api/checkout"]
  CheckoutApi --> Order["Orderテーブル"]
  CheckoutApi --> Stripe["Stripe Checkout Session"]
  Stripe --> Hosted["Stripe hosted checkout"]
  Hosted --> Webhook["/api/webhooks/stripe"]
  Webhook --> Fulfill["fulfillCheckoutSession"]
  Fulfill --> Entitlement["Entitlementテーブル"]
  Hosted --> Success["成功ページ"]
  Success --> ReadOnly["注文状態を表示"]

成功ページは「購入できました」と表示する場所であり、権限付与の主役ではありません。主役はWebhookです。Stripeのイベントを受け取り、署名を検証し、checkout.session.completed などの支払い完了イベントだけをfulfillmentへ渡します。

Claude Codeには、次のように小さく依頼します。

Next.js App RouterでStripe Checkoutを実装します。
成功ページだけで権限付与しないでください。
Webhookはraw bodyで署名検証してください。
fulfillmentは同じCheckout Session IDで2回呼ばれても一度だけ権限付与してください。
秘密鍵をログやプロンプトに出さないでください。
編集前に対象ファイルと設計を説明してください。

商品テーブルと環境変数

まず、価格をブラウザから受け取らない設計にします。クライアントが送るのは productKey だけで、金額とStripe Price IDはサーバー側の表から決めます。

// 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;
}

.env.local はテストモードから始めます。sk_live_ を使うのは、Webhook、返金、権限停止までテストした後です。

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

Prismaで冪等性の土台を作る

本番ではメモリではなくDBに状態を持たせます。StripeEvent.id を主キーにして、同じWebhookイベントが再送されても二重処理を避けます。Entitlement はユーザーが持っている権限です。

// 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

StripeクライアントとCheckout API

Route HandlerはNext.js App Routerの app/api/.../route.ts に置きます。Webhook署名検証やStripe SDKはNode.js前提なので、対象ルートでは runtime = "nodejs" を明示します。

// 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_taxtax_id_collection は「税務が自動で全部終わる」という意味ではありません。住所や税IDの収集、Stripe側の計算、レポート確認を会計処理へ渡しやすくするための設定です。税率、登録義務、インボイス要件は国や事業形態で変わるため、実運用では税理士や会計担当に確認してください。

料金表コンポーネント

料金表は華やかなUIよりも、サーバーで決めた productKey だけを送ることが大事です。表示価格は目安であり、最終価格はStripe Checkout側で確定します。

// src/components/PricingTable.tsx
"use client";

import { useState } from "react";

const cards = [
  {
    productKey: "promptPack",
    name: "Prompt Pack",
    price: "¥3,980",
    description: "教材・テンプレートの一回払い",
  },
  {
    productKey: "proMonthly",
    name: "Pro",
    price: "¥2,980/月",
    description: "小さなSaaSの月額プラン",
  },
  {
    productKey: "workshopDeposit",
    name: "Workshop Deposit",
    price: "¥10,000",
    description: "研修・相談の予約金",
  },
];

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 ? "移動中..." : "Checkoutへ進む"}
          </button>
        </section>
      ))}
    </div>
  );
}

二重クリック対策としてボタンをdisabledにしていますが、これはUX対策です。決済の安全性はUIだけに頼らず、Stripe APIへのidempotency key、WebhookイベントID、DBの一意制約で守ります。

Webhook署名検証とfulfillment

Webhookでは await req.json() を先に呼ばないでください。Stripeの署名検証には改変されていないraw bodyが必要です。Next.jsでは await req.text() で文字列として読み、stripe.webhooks.constructEvent に渡します。

// 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 };
  });
}

ここで重要なのは、FULFILLED の注文をもう一度処理しても権限が増えないことです。StripeのWebhookは再送されることがありますし、成功ページから注文状態を確認する処理を足す場合もあります。fulfillment関数を1つにまとめると、Claude Codeにも「この関数以外で権限を付与しない」とレビューさせやすくなります。

返金と権限停止

返金はStripeでお金を戻すだけでは終わりません。教材のダウンロード権限を残すのか、SaaS権限を止めるのか、研修予約枠を戻すのかを運用ルールとして決めます。以下は管理者だけが呼べる返金APIの最小例です。

// 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 });
}

部分返金で権限を止めるべきかは商品によって違います。教材の一部返金なら権限を残す場合もありますし、予約金の全額返金なら予約枠を解放する必要があります。Claude Codeには「返金額に応じた権限ルールは実装前に質問して」と明示してください。

ローカルテスト手順

Stripe CLIを使うと、ローカルのWebhookへイベントを転送できます。

npm run dev
stripe login
stripe listen --forward-to localhost:3000/api/webhooks/stripe

CLIが表示した whsec_....env.localSTRIPE_WEBHOOK_SECRET に入れ、Next.jsを再起動します。CheckoutではStripeのテストカード番号 4242 4242 4242 4242、未来の有効期限、任意のCVC、任意の郵便番号で成功系を確認できます。失敗系や3D Secureが必要なカードはStripeのTestingを参照してください。

確認するログは4つです。1つ目はCheckout APIがOrderを作ること。2つ目はWebhookで checkout.session.completed が届くこと。3つ目はOrderが FULFILLED になりEntitlementが1件だけ作られること。4つ目は同じWebhookが再送されても件数が増えないことです。

Claude Codeにレビューさせるチェックリスト

実装後は、Claude Codeに修正ではなくレビューをさせます。

この決済差分を批判的にレビューしてください。
特に以下を見てください。
- 成功ページだけで権限付与していないか
- Webhook署名検証前にJSON parseしていないか
- Stripe Event IDとCheckout Session IDで二重処理を防げるか
- ブラウザからpriceIdや金額を信じていないか
- metadataにメール、住所、カード情報などの機微情報を入れていないか
- 返金時にOrderとEntitlementの状態が矛盾しないか
- 税金、領収書、キャンセルポリシーを会計・運用へ引き継げるか

Claude Codeは便利ですが、決済の最終責任者ではありません。APIキーの管理、Stripe Dashboardの設定、税務登録、利用規約、返金ポリシー、ログの個人情報は人間が確認します。特に秘密鍵をプロンプトに貼る、Webhook secretをチャットに貼る、本番APIキーでローカル実験する、という3つは避けてください。

まとめとCTA

Claude Codeで決済を実装するときは、UIから作るよりも「売上が発生した後に何を渡すか」から逆算します。料金表は productKey だけを送り、Checkout APIはサーバー側の商品表からPrice IDを選び、Webhookはraw bodyで署名検証し、fulfillmentは冪等にします。返金と税金は後回しにせず、最初から運用ルールとして設計してください。

より狭いCheckout実装はClaude CodeでStripe Checkoutを実装する完全ガイド、環境変数の扱いはClaude Codeで環境変数管理のベストプラクティスを実装する、Webhook設計はClaude CodeでWebhook実装を安全に進める方法も参考になります。

ClaudeCodeLabでは、Claude Codeを使った決済実装レビュー、社内研修、収益化導線の設計相談を受け付けています。自社のSaaSや教材販売に合わせて、Checkout、Webhook、権限付与、返金ルールまで一緒に固めたい場合は導入相談をご覧ください。すぐ使えるプロンプトやチェックリストは教材一覧にもまとめています。

この記事の内容をローカル検証プロジェクトに貼り、テストモードでCheckout成功、Webhook転送、同一イベントの再送、全額返金を確認しました。一番効いたのは、料金表の前にOrderとEntitlementを設計したことです。UIの見た目より先に「同じ支払いを2回処理しても権限が1回だけ増える」状態を作ると、Claude Codeの差分も人間のレビューもかなり楽になります。

#Claude Code #Stripe #決済 #Webhook #TypeScript
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。