Stripe Checkout最短実装:Session作成・リダイレクト・Webhook確定
Stripe Checkout Sessionを作り、ホスト型ページへリダイレクトし、checkout.session.completedで確定するまで。コピペで動くTypeScriptと、成功ページを信じて事故った失敗談つき。
決済を入れた初日、僕は「支払い完了!」のページを見て、ガッツポーズしました。
その2時間後、Slackに問い合わせが来ます。「お金は引き落とされたのに、教材がダウンロードできません」。Stripeの管理画面を開くと、たしかに入金済み。でもアプリのDBには、購入済みのフラグが立っていない。原因はあとで書きますが、ひとことで言うと成功ページを信じすぎたんです。
カード入力画面を自前で持つのは、正直しんどいです。PCI(カード情報の取り扱い基準)も、3Dセキュアも、100種類以上の支払い方法も、全部こっちで面倒を見ることになる。Stripe Checkoutは、そこをまるごとStripeに預けてしまう仕組みです。自社サイトには「買うボタン」だけ置いて、カード入力はStripeのページでやってもらう。戻ってきたら受け取る。これだけ。
この記事は、その最短ルートだけに集中します。Checkout Sessionを作る → ホスト型ページにリダイレクトする → 戻り先(success/cancel)を決める → Webhookの checkout.session.completed で「本当に払われた」を確定する。サブスクや返金まで含む全体設計はClaude Codeで決済システムを統合する方法に、カート・在庫まで含むEC全体はClaude CodeでECサイトを構築する方法に譲ります。ここはCheckout一本です。
この記事の要点
- Stripe Checkoutは「カード入力ページをStripeに丸投げ」する方式。サーバーでCheckout Sessionを作り、返ってきた
urlにユーザーをリダイレクトするだけで決済画面が立ち上がる。 success_url/cancel_urlで戻り先を指定する。{CHECKOUT_SESSION_ID}を埋め込むと、戻ってきたセッションを特定できる。- 成功ページは決済確定に使わない。確定は必ずサーバー間通信のWebhook(
checkout.session.completed)で受ける。ここを守らないと「入金済みなのに未購入」事故が起きる。 - 画面を自由に作りたい・既存フォームに埋め込みたいならPayment Intents。とにかく速く決済を通したいならCheckout。最初はCheckoutで十分なことが多い。
- コピペで動く最小実装(Session作成API+Webhook)を下に置いた。
sk_test_のテストモードで4242 4242 4242 4242を使えばその場で試せる。
Stripe Checkoutって、要するに何?
ホテルのフロントを思い浮かべてください。チェックインのとき、あなたはクレジットカードをフロントに渡しますよね。部屋(あなたのアプリ)にカード読み取り機を置くわけじゃない。Stripe Checkoutはこの「フロント」です。支払いのやり取りだけ、信頼できる窓口に集約する。
技術的には、Stripeがホスティングしている決済ページ(hosted checkout)にユーザーを送り込みます。流れはこうです。
- ユーザーが自社サイトで「購入」を押す。
- サーバーがStripeに「この商品を、この金額で売りたい」と伝えてCheckout Sessionを1つ作る。
- StripeがそのセッションのURL(
session.url)を返す。 - サーバーはそのURLにユーザーをリダイレクトする。
- ユーザーはStripeのページでカードを入力し、支払う。
- 支払いが終わると、
success_urlに戻ってくる。
カード番号は一度も自社サーバーを通りません。だからPCIの負担がぐっと軽くなる。Stripe公式のCheckout の仕組みでも、この「サーバーでSessionを作り、返ってきたURLへ送る」が基本の型として説明されています。
ここで初心者がよく誤解するのが、Checkout SessionとPayment Intentの違いです。ざっくり言うと、Checkout Sessionは「決済ページ1枚ぶんの注文書」、Payment Intentは「1回の支払いそのもの」。Checkoutを使うと、裏でPayment Intentが自動的に作られます。つまりCheckoutは、面倒な部分を肩代わりしてくれる一段上のレイヤーなんです。
CheckoutとPayment Intents、どっちを使う?
「結局どっちなの」と迷う人が多いので、先に表で割り切ります。
| 観点 | Stripe Checkout | Payment Intents |
|---|---|---|
| カード入力画面 | Stripeのホスト型ページ | 自分で作る(Stripe Elements) |
| 実装の重さ | 軽い(Session作って飛ばすだけ) | 重い(フォーム・状態管理を自前で) |
| 見た目の自由度 | 低め(ロゴ・色・ドメイン程度) | 高い(全部自由) |
| 支払い方法の追加 | ダッシュボード設定で増える | 基本は自前で対応 |
| 向いている人 | とにかく速く決済を通したい | 決済UIを完全に作り込みたい |
僕の経験では、最初の1本は迷わずCheckoutでいいです。理由は単純で、決済まわりは「動いた」だけでは合格じゃないから。自前フォームを作ると、3Dセキュアの分岐、エラー表示、二重送信、対応カードの増減まで全部こっちの宿題になります。Checkoutに預ければ、その宿題ごとStripeが持っていってくれる。デザインにこだわりたくなるのは、売れ始めてからで遅くありません。
逆にPaymentIntentsが要るのは、たとえば「既存の自社チェックアウト画面の中に決済欄を埋め込みたい」「途中の入力をリアルタイムに検証したい」といったケース。そこまでの要件が今ないなら、Checkoutで前に進みましょう。詳しい使い分けは決済システム統合の記事で深掘りしています。
まず動かす:Checkout Sessionを作るAPI
説明より、動かしたほうが早いです。Next.js(App Router)とNode/TypeScriptを前提に、最小のCheckout作成APIを書きます。Stripeアカウントとテストモードの鍵があれば動きます。
依存を入れます。
npm install stripe
.env.local にはテストモードの値だけを置きます。sk_live_(本番鍵)は、Webhookと戻りの確認が全部済むまで触りません。
APP_URL=http://localhost:3000
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxx
STRIPE_PRICE_EBOOK=price_xxxxxxxxxxxxxxxxxxxxx
STRIPE_PRICE_EBOOK は、Stripeダッシュボードの「商品」で作った価格のIDです。金額をコードに直書きせず、Stripe側で管理しておくと、値上げのたびにデプロイし直さずに済みます。
そしてCheckout Session作成のRoute Handler(app/api/checkout/route.ts)。覚えるポイントはたった3つ。①鍵はサーバーだけで使う ②success_url に {CHECKOUT_SESSION_ID} を埋める ③ブラウザには session.url だけ返す。
// app/api/checkout/route.ts
import Stripe from "stripe";
import { NextResponse } from "next/server";
// 鍵はサーバー側だけ。ブラウザには絶対に出さない
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: Request) {
// フロントから「どの商品か」と、注文を追える自社ID(cartId)を受け取る
const body = (await request.json().catch(() => null)) as {
cartId?: string;
} | null;
if (!body?.cartId) {
return NextResponse.json({ error: "cartId is required" }, { status: 400 });
}
const appUrl = process.env.APP_URL!;
// Checkout Session を1つ作る。これが「決済ページ1枚ぶんの注文書」
const session = await stripe.checkout.sessions.create(
{
mode: "payment", // 単発購入。サブスクなら "subscription"
line_items: [{ price: process.env.STRIPE_PRICE_EBOOK!, quantity: 1 }],
// 支払い後の戻り先。{CHECKOUT_SESSION_ID} はStripeが自動で埋める
success_url: `${appUrl}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${appUrl}/pricing?checkout=cancelled`,
// 自社の注文IDを紐づけておく。あとでWebhookと照合できる
client_reference_id: body.cartId,
metadata: { cart_id: body.cartId },
},
// 同じ注文の二重作成を防ぐ。連打やリトライへの保険
{ idempotencyKey: `checkout:${body.cartId}` },
);
// ブラウザには url だけ返す。この url にリダイレクトすれば決済画面が開く
return NextResponse.json({ url: session.url });
}
フロントは、このAPIを叩いて返ってきた url に飛ばすだけです。
// 購入ボタンの onClick など
async function startCheckout(cartId: string) {
const res = await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ cartId }),
});
const { url } = await res.json();
// ここでStripeのホスト型ページへリダイレクト
window.location.href = url;
}
これだけで、ボタンを押すとStripeの決済ページが立ち上がります。サーバー側で res.redirect(303, session.url) の形にしてもいいし、上のようにJSONで url を返してフロントで飛ばしてもいい。どちらもStripe公式が示している型です。
metadata には自社の注文IDのような自分で管理する値だけを入れます。メールアドレス・住所・カード情報みたいな機微な情報は入れないこと。client_reference_id にも注文IDを入れておくと、ダッシュボードの支払いと自社の注文がすぐ照合できて、問い合わせ対応が一気に楽になります。
戻り先(success_url / cancel_url)の落とし穴
ここが、冒頭の事故の核心です。
支払いが終わると、ユーザーは success_url に戻ってきます。{CHECKOUT_SESSION_ID} を埋めておいたので、/checkout/success?session_id=cs_test_xxx のような形で戻る。素直に考えると、「この成功ページが開いた=支払い完了。じゃあここで購入済みにしよう」となりますよね。
僕も最初そう書きました。そして事故った。
理由はこうです。ユーザーは成功ページに必ず戻るとは限らない。支払い直後にブラウザを閉じる人、戻る前に電波が切れる人、決済完了の瞬間にアプリを切り替える人。スマホだと普通に起こります。成功ページが開かなければ、購入済みフラグも立たない。Stripeにはお金が入っているのに、アプリでは未購入。冒頭の問い合わせは、これでした。
正解は、成功ページを「おまけ」扱いにすることです。決済の確定は、次に説明するWebhookで受ける。成功ページは、ユーザーに「ありがとうございました」を見せるためのもの。Stripe公式の注文のフルフィルメントでも、Webhookを必須とし、成功ページからは同じ確定処理を補助的に呼ぶ構成が推奨されています。
本丸:Webhookで決済を確定する
Webhookは、Stripeからあなたのサーバーへのサーバー間の通知です。ユーザーのブラウザを経由しないので、ブラウザが閉じようが電波が切れようが、確実に届きます。だからこれを「正」として扱います。
決済が終わると、Stripeは checkout.session.completed というイベントを送ってきます。これを受け取って、初めて「本当に払われた」と判断する。
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";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
// Edgeランタイムだと raw body を扱いにくい。Nodeで動かす
export const runtime = "nodejs";
export async function POST(request: Request) {
const signature = request.headers.get("stripe-signature");
if (!signature) {
return NextResponse.json({ error: "No signature" }, { status: 400 });
}
// ここが肝。JSONにせず、生の文字列のまま受け取る
const rawBody = await request.text();
let event: Stripe.Event;
try {
// 生本文・署名・Webhook秘密鍵で「本物のStripeからか」を検証
event = stripe.webhooks.constructEvent(
rawBody,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch (err) {
const message = err instanceof Error ? err.message : "unknown";
// 検証に失敗したら400。なりすまし通知を弾く
return NextResponse.json({ error: `Bad signature: ${message}` }, { status: 400 });
}
// 「支払いが完了した」イベントだけを拾う
if (event.type === "checkout.session.completed") {
const session = event.data.object as Stripe.Checkout.Session;
const cartId = session.client_reference_id;
// payment_status が paid のものだけ確定。ここで自社DBを更新する
if (cartId && session.payment_status === "paid") {
await fulfillOrder(cartId, session.id);
}
}
// 重い処理を待たせず、Stripeには素早く2xxを返す
return NextResponse.json({ received: true });
}
// 「権限を渡す/教材を解放する」などの確定処理
async function fulfillOrder(cartId: string, sessionId: string) {
// 例: 同じ cartId が既に確定済みなら何もしない(二重防止)
// await db.order.update({ where: { cartId, fulfilledAt: null }, ... })
console.log(`fulfill order: ${cartId} (session: ${sessionId})`);
}
ポイントは2つ。ひとつは署名検証。constructEvent が、その通知が本当にStripeから来たものかを秘密鍵で確かめてくれます。これがないと、誰でも「払ったよ」という偽の通知を送れてしまう。
もうひとつは素早く2xxを返すこと。Webhandlerの中でメール送信みたいな重い処理を同期でやると、Stripeが「失敗した」と判断して同じイベントを再送します。再送が来ると fulfillOrder が二重に走る。だから確定処理は「同じ注文には一度だけ」効くように書きます(fulfilledAt がまだ空の注文だけ更新する、など)。冪等性の作り込みは決済の生命線なので、決済統合の記事で具体的なテーブル設計まで触れています。
テストモードで実際に通してみる
ローカルでWebhookを受けるには、Stripe CLIが手軽です。
stripe login
stripe listen --forward-to localhost:3000/api/stripe/webhook
stripe listen が表示する whsec_ で始まる値を、.env.local の STRIPE_WEBHOOK_SECRET に入れてアプリを再起動します。あとは購入ボタンから、テストカードで支払うだけ。
- カード番号:
4242 4242 4242 4242 - 有効期限: 任意の未来の日付
- CVC: 任意の3桁
- 郵便番号: 任意
ここで僕が口を酸っぱくして言いたいのは、確認するのは「成功ページが出たか」じゃないということ。本当に見るべきは次です。
- CLIのログに
checkout.session.completedが流れたか。 - 自社DBで注文が「確定済み」になったか。
- 成功ページをリロードしても、確定処理が二重に走らないか(ログが増えないか)。
- 支払い後にブラウザを閉じても(=成功ページを開かなくても)、Webhook側だけで確定するか。
4番をわざと試すのが大事です。決済タブをわざと閉じて、それでもDBが確定済みになる。これを目で見て確認できたら、冒頭の事故はもう起きません。
Claude Codeに任せるときの区切り方
ここからは、この実装をClaude Codeにやらせるときのコツです。決済は「動いた」では合格にならないので、広い権限を一度に渡さないのが結果的にいちばん速い。
僕がやらかした失敗を1つ。最初、「Stripe Checkoutを実装して」とだけ頼みました。返ってきたコードは、見事に成功ページで購入済みにする例の危険パターンでした。Claude Codeが悪いんじゃない。雑に頼んだ僕が悪い。決済ページの作り方として、それが世の中にいちばん多く転がっているからです。
だから今は、作業をこう刻んで渡します。
- Checkout Session作成APIだけを書かせる(鍵はenvから、metadataにPIIを入れない、と明示)。
- 次にWebhook署名検証と確定処理だけを書かせる。「生本文で検証」「同じ注文に一度だけ」を制約として渡す。
- 最後にテスト観点をレビューさせる。成功ページが確定の代わりになっていないか、を名指しでチェックさせる。
レビュー依頼は、こういう短い指示が効きます。
このStripe Checkout実装だけをレビューして。
チェック項目:
- 秘密鍵が環境変数から読まれているか(直書きがないか)
- metadataに個人情報が入っていないか
- Webhookが生のリクエスト本文で署名検証しているか
- 確定処理が二重実行に耐えるか(同じ注文に一度だけ効くか)
- 成功ページがWebhookの代わりに確定処理を担っていないか
問題があればファイル名と行番号を教えて。
それと当然ですが、sk_test_ や whsec_ のような値をプロンプト本文に貼らないこと。.env.example だけ読ませて、実値はローカルに置く。これは習慣にしておくと事故が減ります。
よくある質問
Q. Checkout SessionとPayment Intentは何が違いますか? A. Checkout Sessionは「Stripeのホスト型決済ページ1枚ぶんの注文書」、Payment Intentは「1回の支払いそのもの」です。Checkoutを使うと裏で自動的にPayment Intentが作られます。決済画面を自前で作りたいときだけPayment Intentsを直接使い、それ以外はCheckoutで十分なことが多いです。
Q. 成功ページ(success_url)で購入完了にしてはダメですか?
A. ダメです。ユーザーは支払い後に必ず成功ページへ戻るとは限りません(ブラウザを閉じる、通信が切れる等)。確定はサーバー間通信のWebhook checkout.session.completed で行い、成功ページは表示用に留めるか、補助的に同じ確定処理を呼ぶ程度にします。
Q. success_url の {CHECKOUT_SESSION_ID} は何ですか?
A. Stripeがリダイレクト時に自動で実際のセッションID(cs_test_...)に置き換えるプレースホルダです。success_url=.../success?session_id={CHECKOUT_SESSION_ID} と書いておくと、戻ってきたページでどのセッションかを特定できます。
Q. Webhookが届きません。何を疑えばいいですか?
A. まずテストモードと本番モードの取り違えを疑います。price_ ID・sk_・whsec_ はモードごとに別物です。ローカルなら stripe listen が出した whsec_ を環境変数に入れ直し、アプリを再起動したか確認します。署名検証で400になる場合は、本文をJSON化して生のbodyを壊していないかを見ます。
Q. metadataには何を入れていいですか?
A. 自社で管理する注文IDやカートIDのような識別子だけです。メールアドレス・住所・カード情報などの機微情報は入れません。client_reference_id にも注文IDを入れておくと、ダッシュボードの支払いと自社の注文を照合しやすくなります。
この記事で紹介した内容を実際に試した結果
あの「入金済みなのに未購入」事故のあと、僕の確認手順は1つ変わりました。決済タブをわざと閉じてから、DBを見るようになったんです。成功ページを一度も開かずに、Webhookだけで注文が確定する。これが目で見えた瞬間に、ようやく安心して本番鍵に切り替えられました。
Checkout自体の実装は、本当に短いです。Session作って、url に飛ばして、戻りを受ける。難しいのはコードじゃなくて、「どこを信じるか」の設計でした。成功ページじゃなくWebhookを信じる。これ一行に集約されます。最短実装ほど、この一点だけは手を抜かないでください。
サブスクの権限管理、返金時のステータス戻し、複数商品のカートまで踏み込むなら、決済システム統合の記事とECサイト構築の記事を続けて読むと、設計の穴が一気に埋まります。自社の注文モデルや返金ルールが絡んでこじれそうなら、コードを書く前に運用設計から一緒に固めるのが結局の近道です。手が足りないときは研修・実装相談もどうぞ。
無料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分の型を紹介します。