技術ログ

Stripeで月額課金を実装したときに詰まった3つの落とし穴

公開: 2026-05-19 · 著者: Sasaki Ryuji

個人開発SaaSがサブスク実装で実際にハマった失敗談を、具体的なコード例つきで共有します

Stripeで月額課金を実装したときに詰まった3つの落とし穴

あなたが個人開発で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 トップ技術ログカテゴリでもまとめていますので、よければそちらもどうぞ。