Claude CodeでStripe Checkoutを実装する完全ガイド:Webhookと冪等性まで
Claude CodeでStripe Checkoutを安全に実装する手順。Webhook、metadata、冪等性、返金確認まで実コードで解説。
Stripe Checkoutは「カード入力画面を自社で抱えない」ための近道ですが、決済実装の責任がゼロになるわけではありません。Checkout Sessionを作るAPI、決済後に商品や権限を渡すwebhook fulfillment、注文IDをつなぐmetadata、二重実行を防ぐ冪等性まで設計しないと、売上はStripeにあるのにアプリ側では未購入のまま、という事故が起きます。
Claude Codeに任せる価値が出るのは、単にサンプルコードを書かせる場面ではありません。注文テーブル、環境変数、Next.js Route Handler、webhook署名検証、失敗時のリカバリ観点を小さな作業単位に分け、レビュー可能な差分として出させるときです。
この記事では、Next.js App RouterとNode TypeScriptを前提に、hosted checkoutの実装をコピペで動かせる粒度まで落とします。秘密鍵はコードに直書きせず、テストモードで検証し、最後に返金・キャンセル・非同期決済の確認観点まで整理します。
公式仕様で先に押さえること
Stripe公式のCheckout Sessions APIは、税計算、割引、サブスクリプション、配送、通貨対応などをCheckout Sessionに寄せられるAPIです。hosted checkoutでは、サーバーでSessionを作り、返ってきたURLへユーザーをリダイレクトします。
How Checkout worksでは、ユーザーが支払いを完了したあと、checkout.session.completed のwebhookで注文をfulfillmentする流れが説明されています。fulfillmentは「決済後にデジタル商品を渡す、SaaS権限を付与する、発送指示を出す」処理のことです。
重要なのは、成功ページだけを信用しないことです。公式のFulfill ordersでも、webhookは必須で、成功ページからも同じfulfillment関数を呼ぶ構成が推奨されています。ユーザーが支払い後にブラウザを閉じても、webhookならサーバー間通信で決済完了を受け取れます。
Checkout Sessions API referenceを見ると、Sessionは一回の支払い試行ごとに作るもので、成功後はCustomer、PaymentIntent、Subscriptionの参照を持ちます。metadataには自社の order_id を入れられますが、メールアドレス、住所、カード情報のような機微情報を入れてはいけません。
決済全体のセキュリティ設計は、Claude Codeで決済システムを統合する方法やClaude Codeセキュリティ対策完全ガイドと合わせて読むと見落としが減ります。環境変数の扱いはClaude Codeで環境変数管理のベストプラクティスを実装するも参照してください。
完成形のアーキテクチャ
この記事の構成は、Checkoutの画面をStripeに任せ、注文管理と権限付与を自社DBで管理する形です。
flowchart LR
User["購入者"] --> App["Next.jsアプリ"]
App --> Route["/api/checkout"]
Route --> DB["Orderテーブル"]
Route --> Stripe["Stripe Checkout Session"]
Stripe --> Hosted["Stripe hosted checkout"]
Hosted --> Webhook["/api/stripe/webhook"]
Webhook --> Fulfill["fulfillCheckout"]
Fulfill --> DB
Hosted --> Success["/checkout/success"]
Success --> Fulfill
役割は明確に分けます。Checkout作成APIは「注文を作り、Session URLを返す」だけです。webhookは「Stripe署名を検証し、決済済みSessionだけをfulfillmentに流す」だけです。fulfillment関数は「同じSession IDで何度呼ばれても一度だけ権限を付与する」責務を持ちます。
Claude Codeには、この境界を崩させないのがコツです。「checkout routeを書いて」と頼むだけだと、成功URLで購入済みにする危険なコードが出がちです。作業を「DB schema」「env validation」「Checkout Session作成」「webhook署名検証」「冪等なfulfillment」「テスト手順」に分けて依頼してください。
環境変数と依存パッケージ
まず依存を入れます。Next.js側にTypeScriptのパスエイリアス @/ がある前提です。
npm install stripe zod @prisma/client
npm install --save-dev prisma
npx prisma init
.env.local にはテストモードの値だけを置きます。sk_live_ は動作確認が終わり、webhookと返金確認まで済むまでは使いません。
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
秘密鍵をClaude Codeのプロンプト本文に貼らないでください。Claude Codeには「.env.local の値は読まず、変数名だけを参照する」「ログに process.env を出さない」「sk_ や whsec_ を含む差分があれば止める」と明示します。
注文テーブルを先に作る
Checkout Sessionを作ってから注文を作ると、metadataに安定した order_id を入れられません。先に自社DBに注文を作り、そのIDを client_reference_id と metadata.order_id に渡します。
// 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
}
npx prisma migrate dev --name add_checkout_orders
cartId はフロントエンド側のカートや購入試行に紐づくIDです。ボタン連打やネットワークリトライで同じリクエストが再送されても、同じ cartId なら同じ注文に寄せられます。これがアプリ側の冪等性の入口です。
Stripeクライアントと環境変数検証
環境変数は起動時に検証します。足りない値があるままCheckoutを作るより、開発環境で早く落ちるほうが安全です。
// 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);
APIバージョンをコードで固定するチームもあります。その場合は、インストールしている stripe パッケージが受け付ける型とStripe Dashboard側のAPIバージョンを合わせ、古い日付を記事からコピペしないでください。
Checkout Sessions APIをNext.jsで実装する
次のRoute Handlerは、単発購入とサブスクリプションの両方を扱います。hosted checkoutなので、ブラウザには session.url だけ返します。
// app/api/checkout/route.ts
import Stripe from "stripe";
import { NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { stripe } from "@/lib/stripe";
import { env } from "@/lib/env";
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 });
}
ポイントは4つあります。第一に、Stripeの秘密鍵はサーバー側だけで使います。第二に、metadataには自社DBのIDだけを入れ、個人情報を入れません。第三に、Stripe API呼び出しにも idempotencyKey を渡します。第四に、成功URLで購入済みにせず、あくまでwebhookと共通のfulfillment関数に処理を寄せます。
webhook fulfillmentを冪等にする
webhookは同じイベントが再送されます。成功ページからも同じ関数を呼ぶため、fulfillmentは「複数回呼ばれる」「同時に呼ばれる」前提で書きます。
// 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 };
});
}
実サービスでは、このトランザクション内で「購入権限テーブルに行を作る」「席数を確定する」「発送キューに入れる」などを行います。メール送信や外部SaaS連携のような再試行が必要な処理は、DBにジョブを作ってからワーカーで実行すると安全です。
Stripe署名を検証するwebhook Route Handler
webhookで一番多い失敗は、リクエスト本文をJSONとして先に読んでしまい、署名検証に必要なraw bodyを壊すことです。Next.js Route Handlerでは request.text() を使い、その文字列を constructEvent に渡します。
// 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": {
const session = event.data.object as Stripe.Checkout.Session;
await fulfillCheckout(session.id);
break;
}
case "checkout.session.async_payment_failed": {
const session = event.data.object as Stripe.Checkout.Session;
await markSession(session.id, "FAILED");
break;
}
case "checkout.session.expired": {
const session = event.data.object as Stripe.Checkout.Session;
await markSession(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 });
}
webhookは長時間処理を避け、Stripeへ早く2xxを返します。重い処理を同期で実行すると、Stripeが失敗と判断して再送し、同時実行が増えます。だからこそ、DB更新を冪等にし、外部処理はジョブ化します。
成功ページからも同じfulfillmentを呼ぶ
成功ページはユーザー体験のためにあります。webhookの代わりではありません。ただし、webhookが数秒遅れたときに購入者を待たせないため、同じ fulfillCheckout を呼ぶ価値があります。
// app/checkout/success/page.tsx
import { fulfillCheckout } from "@/lib/checkout-fulfillment";
export default async function CheckoutSuccessPage({
searchParams,
}: {
searchParams: Promise<{ session_id?: string }>;
}) {
const { session_id } = await searchParams;
if (!session_id) {
return (
<main>
<h1>Checkout result</h1>
<p>Session ID was not found. Please contact support.</p>
</main>
);
}
const result = await fulfillCheckout(session_id);
return (
<main>
<h1>Payment received</h1>
<p>Your order status is {result.status}.</p>
<a href="/account/purchases">Open purchases</a>
</main>
);
}
ここで二重処理にならないのは、fulfillment関数側が fulfilledAt: null の注文だけを更新するからです。成功ページが先でも、webhookが先でも、最初の一回だけが権限付与に進みます。
テストモードで確認する手順
ローカルではStripe CLIを使います。
stripe login
stripe listen --forward-to localhost:3000/api/stripe/webhook
CLIが表示した whsec_ から始まる値を STRIPE_WEBHOOK_SECRET に入れます。Next.jsを再起動して、Checkoutボタンからテストカード 4242 4242 4242 4242、任意の未来日、有効なCVC、郵便番号で支払います。
確認するのは、Stripe Dashboard上の成功だけではありません。DBの Order.status が FULFILLED になったか、stripeCheckoutSessionId と stripePaymentIntentId が保存されたか、FulfillmentLog が1行だけか、成功ページを再読み込みしてもログが増えないかを見ます。
テスト観点をClaude Codeに渡すなら、次の粒度が実用的です。
Only inspect the Stripe Checkout implementation.
Verify that secrets are read from env, metadata contains order_id but no PII,
webhook uses raw body signature verification, fulfillment is idempotent,
and success page does not replace webhook fulfillment.
Report file and line references for any issue.
Claude Codeには、テスト用の sk_test_ や whsec_ も貼らない運用が安全です。必要なら .env.example だけを読ませ、実値はローカル環境に置きます。
3つの実例で設計を変える
| ユースケース | Checkout mode | fulfillmentでやること | 注意点 |
|---|---|---|---|
| PDF教材やテンプレート販売 | payment | ダウンロード権限を付与し、領収メールを送る | 成功ページだけでURLを出さない |
| SaaS Proプラン | subscription | プラン権限を有効化し、チーム上限を設定する | customer.subscription.deleted も処理する |
| 研修や相談の予約金 | payment | 予約枠を確定し、日程調整メールを出す | 返金時に予約枠を戻すか運用で決める |
どのケースでも共通するのは、自社DBの注文IDを中心にすることです。StripeのPaymentIntent IDだけで設計すると、サブスクリプションや返金、手動対応のときに業務上の注文と紐づけにくくなります。
よくある失敗とリカバリ
一つ目は、成功URLで即座に購入済みにする失敗です。ユーザーが成功ページへ戻らないだけで未処理になります。webhook必須にして、成功ページは同じ関数を補助的に呼ぶだけにします。
二つ目は、metadataに order_id を入れない失敗です。Stripe Dashboardでは支払いが見えるのに、アプリの注文と照合できません。client_reference_id とmetadataの両方に注文IDを入れると、問い合わせ対応も楽になります。
三つ目は、冪等性をStripe任せにする失敗です。Stripe APIの idempotencyKey は作成リクエストの重複には効きますが、webhook fulfillmentの二重実行は自社DBで止める必要があります。fulfilledAt やユニーク制約で「一度だけ」を表現します。
四つ目は、テストモードと本番モードの混在です。price_ ID、sk_、webhook secretはモードごとに違います。Checkoutが開けてもwebhookが届かない場合は、まずこの組み合わせを疑います。
五つ目は、返金とキャンセルをDashboard上の手作業で終わらせる失敗です。charge.refunded や customer.subscription.deleted を受け、DBのステータス、権限、メール通知まで確認します。特にサブスクリプションは「解約予定」と「即時解約」でサービス提供期間が変わるため、運用ルールを先に決めます。
Claude Codeに任せる作業粒度
決済実装では、Claude Codeに広い権限を渡しすぎないほうが結果が安定します。最初は「対象ファイルを指定」「外部通信はStripe公式ドキュメント確認だけ」「.env.local は読まない」「DB destructive migrationは禁止」と制約を置きます。
依頼は次の順番が扱いやすいです。まずDB schemaと環境変数検証だけを書かせます。次にCheckout Session作成Route Handlerだけを書かせます。次にwebhook署名検証とfulfillmentを作らせます。最後にテスト観点、返金観点、ログの出し方をレビューさせます。
レビューでは、秘密鍵の直書き、metadataのPII、raw bodyを壊す実装、成功ページだけの権限付与、idempotencyKeyの欠落、DB側の二重付与防止漏れを必ず見ます。Claude Codeの出力が動いても、決済では「動いた」だけでは不十分です。
この記事で紹介した内容を実際に試すときの確認ポイント
実際に試すときは、テストモードでCheckoutを完了し、webhookログ、DB更新、成功ページ再読み込み、Checkout Session期限切れ、非同期決済失敗、返金、サブスクリプション解約まで一通り確認してください。Order がStripe Dashboardの支払いと照合でき、同じSession IDを何度処理しても権限が一度だけ付与される状態なら、公開前レビューの土台に乗ります。
ClaudeCodeLabでは、Claude Codeを使った決済実装レビュー、Stripe Checkout導入、チーム向けのセキュリティ研修や実装支援も相談できます。自社の注文モデル、返金ルール、サブスクリプション権限が絡む場合は、コード生成より先に運用設計を一緒に固めるのが最短です。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
ObsidianメモをCLAUDE.mdに変えるClaude Code運用: 文脈を毎回説明しない仕組み
Obsidianの作業メモからCLAUDE.md用の運用ノートを作り、Claude Codeに安定した文脈を渡す方法。
Claude Code Revenue CTA Routing: 記事からPDF、Gumroad、相談へ送る設計
PVだけで終わらせず、読者の状態に合わせて無料PDF、Gumroad教材、導入相談へ分岐するCTA設計です。
Claude Codeチーム引き継ぎルール: レビュー、権限、収益導線まで渡す実務手順
Claude Codeの作業をチームで渡すための証拠、権限、ロールバック、無料PDF/Gumroad/相談導線の実務ルール。