【ITニュース解説】Stripe said "Cancels". My dashboard said "Renews". The webhook returned 200 the whole time.
2026年09月23日に「Dev.to」が公開したITニュース「Stripe said "Cancels". My dashboard said "Renews". The webhook returned 200 the whole time.」について初心者にもわかりやすく解説しています。
ITニュース概要
Stripe連携SaaSで購読キャンセル時、ユーザー画面が「継続」と誤表示されるバグが発生。原因はStripe API変更で、キャンセル判定の参照フィールドが古かったため。APIバージョン違いも判明。サードパーティAPI利用では、フィールド動作確認、ログ活用、APIバージョン統一が重要。
ITニュース解説
ある開発者が、Stripeという外部の決済サービスを利用して提供しているサブスクリプション型サービスで、奇妙なバグに遭遇した。サービスを一般公開する前に、自分自身が顧客として実際にサブスクリプションを契約し、その後キャンセルするというテストを行ったのだ。Stripeの管理画面では、サブスクリプションが「キャンセル済み」と明確に表示され、終了予定日も正しく示されていた。しかし、顧客が実際に利用する自社のダッシュボード画面では、なぜか「更新予定」と表示され続けていたのだ。ページを更新しても表示は変わらず、Stripeからのキャンセル通知(Webhookという自動通知システム)は正常に届き、サーバーのログにもエラーは一切出ていなかった。システムは表面上はすべて「正常」を示しているのに、顧客にとっては最も重要な情報である「もう課金されない」という確認が間違っていたという状況だった。
この問題の原因究明は、システム開発において多くの重要な教訓を教えてくれる。まず、ダッシュボードの表示を決めていたコードは、Stripeから取得した cancel_at_period_end というブーリアン値(真偽値、つまり「はい」か「いいえ」かを示す値)を読み取っていた。このフィールドは、Stripe連携に関する多くの情報源で「期間終了時にキャンセルされる」ことを示すために推奨されていたものだ。コードは cancel_at_period_end が真であれば「キャンセル済み」、偽であれば「更新予定」と表示するように作られていた。しかし、Stripe側の表示と自社ダッシュボードの表示が食い違っていたことから、この cancel_at_period_end が期待通りに「真」になっていないことが明らかになった。
開発者は当初、Stripeから複数のWebhookイベントが異なるタイミングで届くことによって発生する「レースコンディション」ではないかと推測した。これは、複数の処理が同時に行われたときに、その順序によって結果が変わってしまう現象を指す。つまり、キャンセルイベントが届いた後に、古い情報を持つ別のイベントが処理され、最新の状態が上書きされたのではないか、という仮説だ。しかし、このサービスのWebhookハンドラー(Stripeからの通知を受け取るプログラム)は、イベントのデータ(ペイロード)を直接信用せず、常にStripeに問い合わせて最新のサブスクリプション情報を再取得するように設計されていた。そのため、イベントの順序が前後しても、最終的には常に最新の情報がデータベースに反映されるはずだった。この事実から、レースコンディションという仮説は否定された。
次に、具体的な証拠を探すため、Stripeが提供するイベントログツール「Stripe Workbench」が活用された。自社のサーバーログは保存期間が短く、既に該当する情報が消えていたが、Stripeのログには全てのWebhookイベントとその詳細な情報が保存されていたのだ。キャンセルイベントの差分情報(previous_attributes)を確認すると、cancel_at と canceled_at というフィールドは null(値なし)から具体的な日時を示すタイムスタンプに変わっていた。しかし、問題の cancel_at_period_end というフィールドは差分に全く含まれていなかった。これは、このフィールドがキャンセル操作によっても変化していなかったことを明確に示していた。つまり、Stripeは cancel_at_period_end 以外の方法でキャンセルの意図を表現していたのだ。
この現象の真の原因は、StripeのAPI仕様変更、特に「Basil APIリリース」という更新にあった。この変更により、billing_mode が flexible(柔軟な課金モード)なサブスクリプションでは、「期間終了時にキャンセル」という顧客の指示が、直ちに cancel_at という具体的なキャンセル予定日時として記録されるようになった。その結果、cancel_at_period_end は常に false のままで、もはやキャンセルの意図を示すフィールドとしては機能しなくなっていたのだ。開発者のコードが、Stripeの新しいデータモデルに対応していなかったことが問題の根源だった。この変更は、cancel_at_period_end が「非推奨」(将来的に使われなくなる、または機能しない)になったことを意味するが、非推奨フィールドはエラーを発生させずに、ただ情報を伝えなくなるという、見つけにくい特性を持っていた。
修正は比較的シンプルだった。ダッシュボードの表示を制御するコードを、sub.cancel_at_period_end || sub.cancel_at != null と変更した。これは、「cancel_at_period_end が真、または cancel_at というフィールドにキャンセル日時が設定されている場合に『キャンセル済み』と判断する」という意味だ。cancel_at_period_end を残したのは、古いサブスクリプションや将来のAPI変更への「後方互換性」(既存の古い仕組みが引き続き機能すること)を考慮してのことだ。また、cancel_at が設定される全てのケース(顧客ポータルからのキャンセル、Stripeダッシュボードからの手動設定など)が、実際にサブスクリプションが終了することを意味することを確認し、誤った表示になるリスクがないかを慎重に検討した。さらに、この修正が影響する範囲(「Cancels / Renews」という表示ラベルのみ)を事前に確認したことで、万が一問題があっても影響が限定的であり、すぐに元に戻せる(ロールバック)と判断でき、安心して修正をシステムに反映(デプロイ)することができた。
この修正作業中、もう一つの潜在的な問題が発覚した。それは、自社のアプリケーションがStripeと複数のAPIバージョンで通信していたことだ。StripeのSDK(ソフトウェア開発キット)がデフォルトで使用するAPIバージョンと、WebhookエンドポイントがStripeに登録されているAPIバージョンが異なっていたのだ。これは、Stripeのダッシュボードで見たデータの形と、SDKを通じてコードが実際に取得するデータの形が異なる可能性があることを意味する。今回はたまたま両方のバージョンが cancel_at フィールドを正しく扱っていたため問題にはならなかったが、もしバージョン間で大きな変更があった場合、デバッグ時に見たデータと、コードが処理するデータが食い違い、修正が無効になる可能性があった。APIバージョンを明示的に指定し、統一することが恒久的な解決策だが、これは影響範囲が大きいため、今回の小規模な修正とは別に、個別のタスクとして対応することになった。
また、開発者は修正の範囲を意図的に限定した。キャンセル日時の表示について、今回の修正では「期間終了日」をそのまま表示していたが、cancel_at は必ずしも期間終了日と一致しない場合がある。例えば、Stripeダッシュボードで期間途中の任意の日付にキャンセルを設定した場合などだ。しかし、顧客がポータルを通じてキャンセルする場合は、cancel_at と期間終了日は一致するため、今回の修正では表示に大きな問題はなかった。より正確な表示を実現するには、cancel_at を専用のデータ項目としてデータベースに保存し、表示ロジックも変更する必要がある。これはデータベースの構造変更や、複数のコード修正が必要となる大きな作業であり、今回の「緊急の表示バグ修正」に含めるべきではないと判断された。一つのバグを見つけたときに、周辺のすべてを「きれいにする」誘惑に駆られることがあるが、特に決済に関わる重要なコードでは、変更の影響範囲を最小限に抑え、個々の変更を小さく保つことが、リスクを管理し、レビューを容易にする上で非常に重要だ。
修正の検証は、新しいサブスクリプションをわざわざ作成することなく行われた。Stripe Workbenchには、過去のWebhookイベントを再送信する機能がある。この機能を使って、修正後のコードがデプロイされた環境で、以前のキャンセルイベントを再処理させたのだ。開発者のコードはWebhookのペイロードを信用せず、常にStripeから最新情報を再取得する仕組みになっていたため、この再送信によって、修正後のコードが現在のサブスクリプション情報を正しく読み込み、データベースを更新した。その結果、ダッシュボードは期待通り「キャンセル済み」と表示されることを確認できた。
この一連の経験から、システム開発、特に外部サービスとの連携において、多くの重要な教訓が得られた。 第一に、HTTPステータスコードの「200 OK」は、単に「通信が成功した」ことを意味するだけであり、「処理結果が論理的に正しい」ことを保証しない。システムがエラーを吐かなくても、顧客にとっては間違った情報が提供される可能性があることを常に意識すべきだ。 第二に、バグの原因を推測する前に、まず「実際のデータ」を確認することが何よりも重要だ。特にWebhookが絡む問題では、プロバイダー側のイベントログに含まれるペイロードや差分情報が強力な手がかりとなる。 第三に、自社のログだけでなく、外部サービスが提供するログを活用するべきだ。外部サービスはより長い期間、詳細なイベント履歴を保持していることが多く、重要なデバッグ情報源となる。 第四に、APIの「非推奨」になったフィールドは、エラーを発生させず、ただ静かにその役割を終えることがある。型定義が存在し、有効な値を返しても、それがもはや「権威ある情報」ではない場合があるため、常にAPIの変更履歴(チェンジログ)を確認する習慣が重要だ。 第五に、診断に使う情報源(ダッシュボード、Webhookペイロード)と、コードが実際に利用するデータ(SDKで取得するオブジェクト)が、異なるAPIバージョンで提供されている可能性があることを理解し、バージョンが一致しているか、または意図的に固定されているかを確認する必要がある。 第六に、コード変更を行う前に、その変更がシステム内のどこまで影響を及ぼすか(「ブラスト半径」)を把握することは極めて重要だ。影響範囲が小さいと分かれば、安心して迅速にデプロイできるが、もし課金や重要な機能に影響する場合は、より慎重なアプローチが求められる。 最後に、自動テストだけではカバーできない、「顧客が実際に目にする表示」をエンドツーエンドでテストすることの重要性だ。今回のような表示の食い違いは、顧客が実際に操作し、結果を目で確認することでしか発見できなかった。