Stripeで月額課金を実装したときに詰まった3つの落とし穴
個人開発SaaSがサブスク実装で実際にハマった失敗談を、具体的なコード例つきで共有します

あなたが個人開発でSaaSを作っていて、いよいよ月額課金を実装する段階に来たとします。Stripeのドキュメントを読み、サンプルコードをコピペすると、テストモードでは想像以上にスムーズに動きます。しかし、本番運用を始めた瞬間に「あれ、なぜか課金が二重に通った」「カードはあるのに決済が通らない」「解約したのにアプリが使い続けられている」といった問題が次々と発生します。この記事では、GramShift開発で実際に3週間かけて踏み抜いた3つの落とし穴を、当時のコードとエラーメッセージ込みで共有します。読み終わるころには、あなたが避けるべき具体的な実装パターンが見えているはずです。
落とし穴1: webhookの冪等性を雑に扱うと課金が二重に通る
Stripeのwebhookは「同じイベントを複数回送ってくることがある」前提で設計されています。これは公式ドキュメントにも明記されていますが、最初に実装するときは「テストでは1回しか来ないし、まあ大丈夫だろう」と高をくくりがちです。私もそうでした。
GramShiftで初めてcheckout.session.completedイベントを受け取る実装をしたとき、私はこんなコードを書いていました。
// 悪い実装例
fastify.post('/api/stripe/webhook', async (req, reply) => {
const event = stripe.webhooks.constructEvent(
req.rawBody, req.headers['stripe-signature'], WEBHOOK_SECRET
);
if (event.type === 'checkout.session.completed') {
const session = event.data.object;
await db.run(
`UPDATE users SET plan = 'pro', expires_at = ? WHERE id = ?`,
session.expires_at, session.client_reference_id
);
}
return reply.send({ received: true });
});
このコードは、テストモードで決済を1件通すだけなら問題なく動きます。しかし本番運用を始めて1週間ほど経ったある日、ユーザーから「同じ決済通知メールが3通届いた」という問い合わせが来ました。Stripeダッシュボードを確認すると、同じevent.idのwebhookが3回送られていました。原因は、Stripe側のリトライ機構です。最初の応答が遅れたり、500を返したりすると、Stripeは指数バックオフで何度も同じイベントを送ってきます。
正しい実装は「処理済みのevent.idを記録して、重複は無視する」ことです。
// 正しい実装例
fastify.post('/api/stripe/webhook', async (req, reply) => {
const event = stripe.webhooks.constructEvent(
req.rawBody, req.headers['stripe-signature'], WEBHOOK_SECRET
);
// 冪等性チェック: 同じevent.idは1回しか処理しない
const exists = await db.get(
'SELECT 1 FROM stripe_events WHERE event_id = ?', event.id
);
if (exists) {
return reply.send({ received: true, duplicate: true });
}
await db.run(
'INSERT INTO stripe_events (event_id, type, created_at) VALUES (?, ?, ?)',
event.id, event.type, Math.floor(Date.now() / 1000)
);
// ...本来の処理...
return reply.send({ received: true });
});
このパターンを最初から入れておくと、後で課金トラブルの問い合わせに3時間取られる、というような事故を防げます。私が経験した二重課金は最終的に3名で発生し、全員にお詫びと返金対応をして約1万円の損害になりました。
落とし穴2: 3Dセキュア (SCA) 対応を後回しにすると欧州ユーザーが決済できない
2つ目の落とし穴は、3Dセキュア対応です。EUを中心としたSCA (Strong Customer Authentication) 規制により、一定額以上の決済では本人認証が必須になっており、Stripeはこの認証フローを自動で挿入してきます。
私は最初、シンプルなPaymentIntentを作って、フロント側でstripe.confirmCardPayment()を呼ぶだけの実装にしていました。日本のテストカード (4242 4242 4242 4242) では完璧に動きます。しかし、本番リリース後にブラジル在住のユーザーから「決済ボタンを押すと『追加認証が必要』というエラーが出て、それ以降進めない」という報告が来ました。
原因は、3Dセキュア認証画面の表示処理を実装していなかったことです。StripeはPaymentIntent.status === 'requires_action'を返してきていたのですが、私のコードはこのケースをハンドリングしていませんでした。
// 3Dセキュア対応版
const result = await stripe.confirmCardPayment(clientSecret, {
payment_method: { card: cardElement, billing_details: { name } }
});
if (result.error) {
// カードエラー or 認証失敗
showError(result.error.message);
} else if (result.paymentIntent.status === 'requires_action') {
// 3Dセキュア認証が必要 → Stripe SDKが自動で処理してくれる
const { error } = await stripe.handleCardAction(
result.paymentIntent.client_secret
);
if (error) showError(error.message);
else showSuccess();
} else if (result.paymentIntent.status === 'succeeded') {
showSuccess();
}
テストするときは、Stripeが提供している4000 0025 0000 3155 (常に3Dセキュア認証を要求するテストカード) を使います。このカードでフローが通れば、欧州・ブラジル・オーストラリアのユーザーでもほぼ問題なく決済できます。私は本番リリースから2週間後にこの対応を入れましたが、その間に約2名のユーザーから「決済できない」とサポートに来ており、機会損失になっていました。
落とし穴3: 解約処理を「即時 vs 期末」で雑に決めると返金トラブルになる
3つ目の落とし穴は、解約時の挙動設計です。Stripe Subscriptionには「即時解約 (subscription.delete)」と「期末解約 (cancel_at_period_end = true)」の2種類があり、それぞれUXとビジネス影響が大きく異なります。
私はGramShiftの初期実装で、深く考えずに「ユーザーが解約ボタンを押したら即時解約」にしていました。シンプルで実装も短いからです。しかし、これがいくつかの問題を生みました。
- 月初に解約したユーザーから「払ったばかりなのに即停止された、日割り返金してほしい」というクレーム
- 解約直後に「やっぱり戻したい」と言われた場合、新規登録扱いになって設定がリセットされる
- 「自動更新オフ」と「即時解約」の違いがUIから読み取れず、誤操作が発生
結局、私は実装を以下のように変えました。解約ボタンを押すと、デフォルトでは期末解約に設定し、「次回更新日まで使えます」と明示する。即時解約したい場合は別の「すぐに停止して返金を申請」ボタンから手動で問い合わせる動線にしました。
// 期末解約をデフォルトに
async function cancelAtPeriodEnd(subscriptionId) {
return stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: true
});
}
// 表示側
function renderCancelButton(sub) {
const endDate = new Date(sub.current_period_end * 1000)
.toLocaleDateString('ja-JP');
return `<button>次回更新 (${endDate}) で解約</button>`;
}
このUX変更後、解約に関するサポート問い合わせは月3件から月0-1件に減りました。期末解約をデフォルトにする設計は、ユーザーが「払った分は使い切れる」という安心感をもたらし、結果として解約自体を撤回するケース (期末までに気が変わって解約取り消し) も生まれます。私の運用データでは、期末解約に設定したユーザーのうち約15%が、解約予定日までに翻意して継続契約に戻ってきました。
3つの落とし穴を最初から避けるためのチェックリスト
あなたがこれからStripeで月額課金を実装するなら、最低限以下の4点を最初から押さえておくと、私が3週間かけて踏み抜いた失敗を回避できます。
- webhookは必ず冪等性チェック: event.idで処理済みを記録するテーブルを最初から作る
- 3Dセキュアフローを最初から実装:
requires_actionステータスを必ずハンドリング、テストカード4000 0025 0000 3155で確認 - 解約はデフォルトを期末に:
cancel_at_period_endを使う、即時解約は別動線 - 本番リリース前に1ヶ月だけ自分でサブスクを回す: 月初・月末・解約・再開を一通り試して、ユーザー視点で違和感がないかチェック
個人開発SaaSの決済まわりは、一度トラブルが起きると返金対応・お詫びメール・コード修正で半日が消えます。最初から「Stripeが想定するエッジケース」を全部押さえておくことが、長期的に開発時間を生む投資になります。
個人開発の他の実装記録は、SaaS Diary トップや技術ログカテゴリでもまとめていますので、よければそちらもどうぞ。