成功画面で「購入済み」にして事故った僕のStripe決済実装メモ
Stripe決済を成功ページで確定して権限がズレた話から、Payment Intents・Checkout・Webhook確定・冪等性キー・返金・テストカード検証まで、Claude Codeで安全に組む手順を実コードで。
決済を初めて自前で組んだ日のことを、僕はわりと鮮明に覚えています。
テストカードで「支払い成功」のページが出た。やった、と思って、その成功ページで「この人は買った人」とフラグを立てた。ローカルでは完璧に動きました。
ところが知人にテストしてもらったら、支払ったのに商品が届かない人が出た。決済直後にブラウザを閉じた人です。お金はStripeに入っているのに、僕のDBは「未購入」のまま。逆に、決済を途中でやめてリロードを連打した人には、なぜか二重で権限が付いていた。
原因はシンプルでした。「支払いが終わったかどうか」を、成功ページという一番あてにならない場所で判定していたんです。ブラウザは閉じられるし、リロードされるし、回線も切れる。あそこは「ありがとうございました」を出す場所であって、お金の事実を確定する場所じゃなかった。
この記事は、その失敗をやり直すつもりで書きます。Stripeの決済フローだけに集中します。カートや在庫を含むECサイト全体の作り方は姉妹記事のClaude CodeでECサイトを自作する手順に分けたので、ここでは「お金をどう確定し、どう商品を渡し、どう返すか」を実コードで詰めていきます。
この記事の要点
- 支払いの確定は成功ページではなくWebhookでやる。これだけで事故の大半が消える。
- 一回払いか月額かで道具を選ぶ。手早く出すならCheckout、決済UIを自前で作りたいならPayment Intents。Stripe公式は多くの場合Checkoutを薦めている。
- 同じ通知が2回来ても権限が増えないよう、冪等性キーとDBの一意制約で二重処理を止める。
- 返金はお金を戻すだけで終わらない。権限の停止までを1セットで設計する。
- 本番に出す前にテストカードでWebhook再送・非同期決済・全額返金まで通す。
まず「価格はサーバーが決める」を徹底する
最初に一番やられやすい事故を潰します。金額をブラウザから受け取らないことです。
フロントから amount: 500 みたいに送られてきた値をそのまま課金に使うと、開発者ツールで 5 に書き換えられて5円で売れます。冗談みたいですが、実在する攻撃です。
なので、クライアントが送るのは「どの商品か」を表す 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",
},
} 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(`知らないproductKey: ${productKey}`);
}
export function getPriceId(priceEnv: string) {
const priceId = process.env[priceEnv];
if (!priceId) throw new Error(`環境変数が未設定: ${priceEnv}`);
return priceId;
}
entitlement は「支払い後にこの人へ渡す権利」です。教材のダウンロード権なのか、Proプランの機能解放なのか。ここを商品の表に持っておくと、後の権限付与と返金がぶれません。環境変数の置き方そのものはClaude Codeで環境変数を安全に管理する方法に寄せたので、本番キーの扱いはそちらを見てください。
CheckoutとPayment Intents、どっちで作るか
Stripeでカード決済を受ける入り口は、大きく2つあります。混乱しやすいので、先に違いを置きます。
| Checkout | Payment Intents | |
|---|---|---|
| 決済画面 | Stripeのホスト画面に飛ばす | 自分のサイト内に作る |
| 書く量 | 少ない(数十行で動く) | 多い(UIも自前) |
| 向いている人 | とにかく早く売りたい | 決済UIを自社デザインに溶かしたい |
| サブスク | mode: "subscription" で対応 | 別途Subscriptionと組む |
| Stripeの推奨 | 多くの組み込みでこちら | 必要なときだけ |
正直に言うと、Stripe公式は「ほとんどの場合はPayment ElementとCheckoutでよくてPayment Intentsは要らない」という立場です(Payment Intents API)。僕も最初の収益化は全部Checkoutで出しました。書く量が段違いに少なく、3Dセキュアや各国の決済手段もStripe側が面倒を見てくれます。
それでも「決済画面を自分のサイトの中に置きたい」「カート画面と一体化したい」ときはPayment Intentsの出番です。この記事は両方の最小コードを出すので、要件で選んでください。
Checkoutで一回払いとサブスクを作る
まずCheckout。mode を catalog.ts の値で切り替えるだけで、一回払いとサブスクの両方を同じAPIで捌けます。
// src/app/api/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import Stripe from "stripe";
import { prisma } from "@/lib/db";
import { getBillingProduct, getPriceId } from "@/lib/billing/catalog";
import { requireUser } from "@/lib/auth";
export const runtime = "nodejs"; // Stripe SDKはNode前提。Edgeにしない
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
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);
// 先にOrderを作る。決済の事実は後でWebhookが上書きする
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, // "payment" か "subscription"
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 },
success_url: appUrl("/checkout/success?session_id={CHECKOUT_SESSION_ID}"),
cancel_url: appUrl("/pricing"),
},
{ idempotencyKey: `checkout:${order.id}` } // 同じOrderで二重に作らせない
);
await prisma.order.update({
where: { id: order.id },
data: { stripeCheckoutSessionId: session.id },
});
if (!session.url) {
return NextResponse.json({ error: "Checkout URLが作れませんでした" }, { status: 500 });
}
return NextResponse.json({ url: session.url });
}
function appUrl(path: string) {
const base = process.env.APP_URL ?? "http://localhost:3000";
return `${base.replace(/\/$/, "")}${path}`;
}
ポイントは3つ。line_items の金額はサーバーのPrice IDから引いている。metadata.order_id で後からどの注文か辿れる。idempotencyKey で「リロード連打で二重にSession作成」を止めている。サブスク特有の解約・支払い失敗の扱いはStripeサブスクで二重課金を出した話に詳しく書いたので、月額をやるならあわせて読んでください。
自前UIで受けるならPayment Intents
決済画面を自社サイトに埋め込みたいなら、サーバーでPayment Intentを作り、client_secret だけをフロントに渡します。PaymentIntentオブジェクトを丸ごと返さないのがコツです。余計な情報を表に出さないためです。
// src/app/api/payment-intents/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import Stripe from "stripe";
import { prisma } from "@/lib/db";
import { getBillingProduct, getPriceId } from "@/lib/billing/catalog";
import { requireUser } from "@/lib/auth";
export const runtime = "nodejs";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const schema = z.object({ productKey: z.string().min(1) });
export async function POST(req: NextRequest) {
const user = await requireUser();
const { productKey } = schema.parse(await req.json());
const product = getBillingProduct(productKey);
// 金額はPrice IDから取得する(フロントの値は信じない)
const price = await stripe.prices.retrieve(getPriceId(product.priceEnv));
if (!price.unit_amount || !price.currency) {
throw new Error("価格情報が取得できませんでした");
}
const order = await prisma.order.create({
data: { userId: user.id, productKey: product.key, entitlement: product.entitlement },
});
const intent = await stripe.paymentIntents.create(
{
amount: price.unit_amount,
currency: price.currency,
receipt_email: user.email,
automatic_payment_methods: { enabled: true }, // 使える決済手段をStripeに任せる
metadata: { order_id: order.id, product_key: product.key },
},
{ idempotencyKey: `pi:${order.id}` }
);
await prisma.order.update({
where: { id: order.id },
data: { stripePaymentIntentId: intent.id },
});
// フロントには client_secret だけ渡す
return NextResponse.json({ clientSecret: intent.client_secret });
}
フロント側はStripe.jsのPayment ElementにclientSecretを渡してconfirmPaymentを呼ぶ形になりますが、確定の判定はここでもフロントでやりません。confirmPaymentの戻り値で画面表示は切り替えていいものの、「お金が入った」という事実はサーバーのWebhookでpayment_intent.succeededを受けて確定します。これはStripe公式も明記しているベストプラクティスです。
冪等性の土台をDBに置く
ここが今日の核心です。最初の僕が落ちた穴を、構造で塞ぎます。
決済の確定をメモリ変数や成功ページに置くと、再送・再起動・リロードで壊れます。代わりに、DBに「処理済みのStripeイベントID」を記録し、同じIDは二度処理しないようにします。
// prisma/schema.prisma
model Order {
id String @id @default(cuid())
userId String
productKey String
entitlement String
status String @default("PENDING") // PENDING / FULFILLED / REFUNDED / CANCELED
stripeCheckoutSessionId String? @unique
stripePaymentIntentId 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
@@unique([userId, key]) // 同じ権利は1人1行。二重付与を物理的に防ぐ
}
model StripeEvent {
id String @id // StripeのイベントIDをそのまま主キーに
type String
createdAt DateTime @default(now())
}
StripeEvent.id を主キーにしているのが効きます。同じイベントを2回受けると、2回目のinsertが一意制約で落ちる。その失敗を「もう処理済み」のサインとして使えば、二重処理は一発で止まります。
Webhookで支払いを確定し、商品を渡す
いよいよ確定処理です。注意点がひとつだけあって、req.json() を先に呼んではいけません。Stripeの署名検証には、改変されていない生のリクエストボディ(raw body)が要ります。JSONに変換すると検証に失敗します。Next.jsではreq.text()で文字列のまま受けます。
// src/app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { prisma } from "@/lib/db";
import { fulfillOrder } from "@/lib/billing/fulfillment";
export const runtime = "nodejs";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: NextRequest) {
const payload = await req.text(); // ここはtext。jsonにしない
const signature = req.headers.get("stripe-signature");
if (!signature || !process.env.STRIPE_WEBHOOK_SECRET) {
return NextResponse.json({ error: "署名がありません" }, { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(payload, signature, process.env.STRIPE_WEBHOOK_SECRET);
} catch {
return NextResponse.json({ error: "署名が不正です" }, { 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;
const orderId = session.metadata?.order_id;
if (orderId) await fulfillOrder(orderId, `stripe:${session.id}`);
}
if (event.type === "payment_intent.succeeded") {
const intent = event.data.object as Stripe.PaymentIntent;
const orderId = intent.metadata?.order_id;
if (orderId) await fulfillOrder(orderId, `stripe:${intent.id}`);
}
return NextResponse.json({ received: true });
}
商品を渡すfulfillOrderは1つに統一します。CheckoutでもPayment Intentsでも、最終的にここを通す。権限を付けるコードがアプリ中に1か所しかない状態にしておくと、レビューが一気に楽になります。
// src/lib/billing/fulfillment.ts
import { prisma } from "@/lib/db";
export async function fulfillOrder(orderId: string, source: string) {
return prisma.$transaction(async (tx) => {
const order = await tx.order.findUnique({ where: { id: orderId } });
if (!order) throw new Error(`注文が見つかりません: ${orderId}`);
// すでに渡し済みなら、何もしないで返す(二重付与の最後の砦)
if (order.status === "FULFILLED") {
return { status: "already_fulfilled" as const };
}
await tx.order.update({
where: { id: order.id },
data: { status: "FULFILLED", fulfilledAt: new Date() },
});
// 一意制約 + upsert で、何度呼ばれても権限は1行のまま
await tx.entitlement.upsert({
where: { userId_key: { userId: order.userId, key: order.entitlement } },
update: { active: true, source },
create: { userId: order.userId, key: order.entitlement, active: true, source },
});
return { status: "fulfilled" as const };
});
}
これで、冒頭の僕の事故は両方とも消えます。ブラウザを閉じた人にはWebhookが届いてfulfillOrderが走る。リロードを連打した人には、イベントID重複とFULFILLEDチェックと一意制約の三重ロックで、権限は1回しか付かない。成功ページは「ありがとうございました」を出すだけの、安全な飾りに戻ります。
返金は「お金+権限」をセットで戻す
返金でやりがちなのが、Stripeのダッシュボードでポチッと返金して終わりにすること。お金は戻りますが、アプリ側の権限は生きたまま残ります。教材は返金後もダウンロードし放題、というやつです。
返金APIは、お金を戻すのと権限を止めるのを同じトランザクションで行います。管理者だけが叩けるようにします。
// src/app/api/admin/refunds/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import Stripe from "stripe";
import { prisma } from "@/lib/db";
import { requireAdmin } from "@/lib/auth";
export const runtime = "nodejs";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const schema = z.object({ orderId: z.string().min(1), amount: z.number().int().positive().optional() });
export async function POST(req: NextRequest) {
await requireAdmin();
const { orderId, amount } = schema.parse(await req.json());
const order = await prisma.order.findUnique({ where: { id: orderId } });
if (!order?.stripePaymentIntentId) {
return NextResponse.json({ error: "返金できる支払いがありません" }, { 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に任せるなら「返金額に応じた権限ルールは実装前に必ず質問して」と先に釘を刺しておくと安全です。
テストカードで本番前に通す4項目
ローカルでWebhookを試すには、Stripe CLIでイベントを自分のサーバーへ転送します。
npm run dev
stripe login
stripe listen --forward-to localhost:3000/api/webhooks/stripe
CLIが表示するwhsec_...を.env.localのSTRIPE_WEBHOOK_SECRETに入れ、Next.jsを再起動します。Checkoutでは、テストカード 4242 4242 4242 4242、未来の有効期限、任意のCVCと郵便番号で成功します。失敗系や3Dセキュアが要るカードはStripeのテスト用カード一覧にあります。
僕が必ず確認するのは次の4つです。本番キーに切り替えるのは、この全部が緑になってからです。
- Checkout APIを叩くとOrderが
PENDINGで作られる。 - 決済すると
checkout.session.completed(またはpayment_intent.succeeded)が届く。 - Orderが
FULFILLEDになり、Entitlementがちょうど1件できる。 stripe events resend <id>で同じイベントを再送しても、件数が増えない。
特に4番。再送して権限が増えないことを目で見るまで、僕は「冪等にできた」と言わないようにしています。
Claude Codeには実装よりレビューをさせる
決済は、売上・個人情報・法務に同時に触れます。なので僕はClaude Codeに「Stripe決済を作って」と丸投げしません。設計を決めて骨格を書いたあと、批判的なレビュー担当として使います。丸投げすると、成功ページで購入済みにする例の危険な実装や、署名検証を飛ばしたコードが平気で出てきます。
この決済差分を批判的にレビューしてください。特に:
- 成功ページだけで権限付与していないか
- Webhook署名検証の前にreq.json()していないか
- StripeイベントIDで二重処理を防げているか
- フロントから来たpriceや金額を信じていないか
- metadataにメール・住所・カード情報を入れていないか
- 返金時にOrderとEntitlementの状態が矛盾しないか
秘密鍵をプロンプトに貼る、Webhook secretをチャットに貼る、本番キーでローカル実験する。この3つだけは絶対にやらないでください。最終責任者は人間です。
よくある質問
Q. CheckoutとPayment Intents、結局どっちを使えばいい? 迷ったらCheckoutです。Stripe公式も多くの組み込みでCheckoutを推奨しています。決済画面を自社サイト内に作り込みたい、カートと一体化したい、という明確な理由があるときだけPayment Intentsにしてください。
Q. なぜ成功ページで権限を付けてはいけないの? 成功ページはブラウザが閉じられたり、リロードされたり、回線が切れたりするからです。お金が入った事実はStripeのWebhookでしか確実に分かりません。成功ページは表示専用、確定はWebhook、と役割を分けます。
Q. 冪等性キーは何を入れればいい?
注文IDなど、その操作を一意に表す文字列です。checkout:注文ID のように作ります。Stripe側はこのキーで最初の結果を24時間以上保持し、同じキーの再試行には同じ結果を返します。メールアドレスなど機微情報はキーに使わないでください。
Q. サブスクの解約や支払い失敗はどう扱う?
customer.subscription.deleted や invoice.payment_failed をWebhookで受けて権限を更新します。この記事は一回払い中心なので、解約処理の実コードはStripeサブスクの冪等性記事にまとめてあります。
Q. 日本の消費税やインボイスはこのコードで足りる? 足りません。Stripe Taxで計算を寄せることはできますが、税率・登録義務・インボイス要件は事業形態で変わります。実運用では税理士や会計担当に必ず確認してください。コードは「会計に渡しやすくする」までが役割です。
実際に試した結果
この構成を、自分の小さな教材販売の試作にそのまま貼ってみました。テストモードで、Checkout成功・Webhook転送・同一イベントの再送・全額返金まで一通り流しました。
一番効いたのは、料金表やボタンのデザインに手を付ける前に、OrderとEntitlementとStripeEventの3テーブルを先に決めたことです。「同じ支払いを2回処理しても権限が1回しか増えない」状態を構造で作ってしまうと、あとからWebhookを足してもPayment Intentsに切り替えても、土台が崩れません。Claude Codeに差分を見せたときのレビューも、人間の見直しも、驚くほど短くなりました。
決済は、賢いコードより戻せて、ぶれない設計が先です。成功画面で確定して事故った僕からの、いちばん伝えたい結論です。具体的な実装プロンプトやレビュー用チェックリストは教材一覧にもまとめています。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。