結論
Stripe を初めて組み込むなら、Checkout Sessions + Webhook の組み合わせがいちばん事故りにくいです。
フロントは「決済ページへ飛ばす」、サーバーは「金額を決めてセッションを発行する」、Webhook は「最終的な注文確定を反映する」と責務を分けてください。
全体フロー
1. ユーザーが購入ボタンを押す
2. 自社サーバーが注文IDを作る
3. サーバーが Stripe Checkout Session を作る
4. ブラウザを Stripe Checkout へリダイレクトする
5. 決済完了後、ユーザーは success_url に戻る
6. Stripe から webhook が届く
7. webhook を正として注文を paid に更新する
この構成なら、ユーザーが success ページを閉じても、Webhook さえ受けられれば内部状態を正しく保てます。
Step 1: サーバー側に商品マスターと注文モデルを置く
最初にやるべきことは、金額をクライアントに決めさせない ことです。
type Plan = {
name: string;
amount: number;
currency: 'jpy';
};
const PLANS: Record<string, Plan> = {
starter: { name: 'Starter', amount: 1200, currency: 'jpy' },
pro: { name: 'Pro', amount: 3500, currency: 'jpy' }
};
さらに、自社 DB 側では最低限これだけ持っておくと後で楽です。
| カラム | 用途 |
|---|---|
id | 自社の注文 ID |
status | pending paid failed refunded など |
amount | 自社で確定した請求額 |
currency | 通貨 |
stripeSessionId | Checkout Session との対応付け |
stripePaymentIntentId | 後で追跡するための参照 |
paidAt | 反映時刻 |
Step 2: Checkout Session を作る API を用意する
以下は Node.js + Express を想定した最小構成です。
フレームワークは違っても、考え方は同じです。
import express from 'express';
import Stripe from 'stripe';
const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
app.post('/api/checkout/session', express.json(), async (req, res) => {
const { planId, userId } = req.body;
const plan = PLANS[planId];
if (!plan) {
return res.status(400).json({ error: 'invalid_plan' });
}
const order = await db.orders.insert({
userId,
planId,
amount: plan.amount,
currency: plan.currency,
status: 'pending'
});
const session = await stripe.checkout.sessions.create(
{
mode: 'payment',
line_items: [
{
price_data: {
currency: plan.currency,
unit_amount: plan.amount,
product_data: {
name: plan.name
}
},
quantity: 1
}
],
success_url: `${process.env.APP_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.APP_URL}/checkout/cancel`,
client_reference_id: order.id,
metadata: {
orderId: order.id
}
},
{
idempotencyKey: `checkout-session:${order.id}`
}
);
await db.orders.update(order.id, {
stripeSessionId: session.id
});
return res.json({ url: session.url });
});
ポイントは3つです。
amountは必ずサーバーのマスターから決めるorder.idをmetadataとclient_reference_idに入れるidempotencyKeyを付けて二重作成を防ぐ
Step 3: フロントは API を叩いてリダイレクトするだけにする
const buy = async (planId: string) => {
const response = await fetch('/api/checkout/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ planId, userId: currentUser.id })
});
const data = await response.json();
if (!response.ok || !data.url) {
throw new Error('checkout_session_create_failed');
}
window.location.assign(data.url);
};
フロントでやるべきことはここまでです。
金額計算、割引適用、注文確定まで全部フロントでやる設計は壊れやすくなります。
Step 4: success ページは「結果表示」に徹する
success ページでは、session_id を受け取って注文内容を表示して構いません。
ただし、success ページが表示されたことをもって受注確定にしない でください。
理由は単純で、ユーザーが戻り先に来ないケースがあるからです。
Stripe 公式も、支払いの履行は Webhook を使う前提で説明しています。
Step 5: Webhook で注文確定を反映する
Webhook は raw body のまま署名検証する必要があります。
JSON にパースした後では署名検証に失敗します。
Express では、Webhook ルートだけ express.raw() を使い、共通の JSON parser を先に当てない のが基本です。
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
app.post(
'/api/stripe/webhook',
express.raw({ type: 'application/json' }),
async (req, res) => {
const signature = req.headers['stripe-signature'];
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.body,
signature as string,
endpointSecret
);
} catch (error) {
return res.status(400).send('invalid_signature');
}
if (
event.type === 'checkout.session.completed' ||
event.type === 'checkout.session.async_payment_succeeded'
) {
const checkoutSession = event.data.object as Stripe.Checkout.Session;
const orderId = checkoutSession.metadata?.orderId;
if (!orderId) {
return res.status(400).send('missing_order_id');
}
await db.transaction(async () => {
const order = await db.orders.findById(orderId);
if (!order || order.status === 'paid') return;
await db.orders.update(orderId, {
status: 'paid',
stripePaymentIntentId: String(checkoutSession.payment_intent ?? ''),
paidAt: new Date()
});
});
}
if (event.type === 'checkout.session.async_payment_failed') {
const checkoutSession = event.data.object as Stripe.Checkout.Session;
const orderId = checkoutSession.metadata?.orderId;
if (orderId) {
await db.orders.update(orderId, { status: 'failed' });
}
}
return res.json({ received: true });
}
);
ここで重要なのは、同じイベントが再送されても壊れないこと です。
Webhook は一回だけ届く前提で組んではいけません。
Step 6: ローカルでテストする
Stripe CLI を使うとローカルでかなり確認しやすくなります。
stripe listen --forward-to localhost:3000/api/stripe/webhook
stripe trigger checkout.session.completed
CLI の listen で出た whsec_... を STRIPE_WEBHOOK_SECRET に使ってください。
Dashboard のエンドポイント用 secret と混ぜると、署名検証が通らなくなります。
つまづきポイント
1. クライアントから送られた金額をそのまま使う
これは最初にやりがちな事故です。価格改ざんにも、プラン不整合にも弱くなります。
2. success ページで受注確定してしまう
戻り先ページは補助導線です。確定の責務は Webhook に寄せてください。
3. express.json() を webhook より先に適用する
Stripe は raw body を要求します。
Express では middleware の順番がずれるだけで署名検証が失敗します。
Webhook ルートは route-specific に express.raw() を使うと事故が減ります。
4. 遅延決済のイベントを見ない
カードだけなら目立ちませんが、銀行振込など即時でない手段を有効にすると checkout.session.async_payment_succeeded を見ない設計は危険です。
5. テスト鍵と本番鍵を混ぜる
sk_test_ と sk_live_ の混在は非常に多いです。
Webhook secret も test と live で別物です。
6. 内部注文 ID と Stripe オブジェクトを紐づけない
metadata.orderId がないと、本番障害時にダッシュボードと自社 DB を横断して追うのが難しくなります。
Checkout から Payment Element に進むタイミング
次の要件が出たら、Payment Element への移行を考える価値があります。
- 決済フォームを他の入力欄と完全に一体化したい
- フロントの UI 分岐を細かく作りたい
- 決済前後の体験をブランドとして強く作り込みたい
逆に、最初の公開段階では無理に移らなくて構いません。
併読推奨
- Stripe Checkout と Payment Element の違いを実装目線で比較する
- PaymentIntent の状態遷移を実装事故ベースで理解する
- 決済 API の冪等性を実装事故ベースで理解する
- Stripe Webhook で起きやすい失敗を運用視点で10個に整理する
- 決済システム運用で事故を減らすチェックリストを作る
よくある質問
初学者は Payment Element から始めるべきですか?
最初の1本なら Checkout の方が安全です。要件が固まってから Payment Element へ進んでも遅くありません。
成功画面に戻ってきたら注文確定して大丈夫ですか?
それだけでは不十分です。注文確定の正は Webhook に寄せてください。
Webhook で何のイベントを拾えばよいですか?
まずは checkout.session.completed を基準にし、遅延決済を使うなら checkout.session.async_payment_succeeded も見ます。