~/ tanmen

~/posts / silent-failures-in-monetization

StripeとCloudflareで有料化して踏んだ、エラーが出ない罠4つ

個人開発のデスクトップツールを有料化して、決済から利用開始までを通した。技術的に難しいことは何もしていない。それでも4回止まった。

止まった原因が4つとも同じ性質だった。ビルドは緑。webhook は 200 を返し、テストも通る。それなのに製品としては壊れている、という種類の失敗ばかりだ。同じ構成なら、たぶん同じ場所で止まる。

前提

  • 2026年7月時点
  • アプリは Python 3.10 + PySide6。PyInstaller で exe にして Windows 向けに配布
  • 決済は Stripe の Payment Link(月額 ¥100 / 年額 ¥1,000)
  • ライセンス発行は Cloudflare Workers + D1。Stripe の webhook を受けてキーを作る
  • LP は Cloudflare Pages、配布物は R2

罠1: secret を取り違えると、期限の無いライセンスが出る

4つの中でこれが一番怖い。金銭的な損害が出るのはこれだけだ。

症状から書く。購入するとライセンスキーが発行され、ユーザーの画面に正しく表示される。webhook は 200 を返す。D1 にもレコードが入る。どこにも問題が見当たらない。

原因は、STRIPE_SECRET_KEYwhsec_...(Webhook の署名シークレット)を入れていたこと。すると何が起きるか。

  1. 署名検証は別の環境変数(STRIPE_WEBHOOK_SECRET)を見るので、そちらが正しければ通る
  2. webhook は 200 を返し、ライセンスキーも発行される
  3. しかし Stripe API 呼び出しが 401 になり、サブスクリプションの情報が取れない
  4. 結果、expires が空のライセンスができる
  5. 空の期限は「期限なし」として扱われ、時間では失効しなくなる

解約そのものは止まる。Stripe から届くイベントで statuscanceled に変わり、次の再確認で弾かれるからだ。消えるのは期限による自然失効のほうで、これが効かないと、サーバに再確認が届かない限り手元の控えは Pro のまま残り続ける。課金と使える期間が静かにずれていく。

購入者からは完全に正常に見えるので、誰も報告してくれない。

見るべき1箇所

決め手は expires だけを見ることだ。

npx wrangler d1 execute kawarin-licenses --remote --command "SELECT key,status,expires FROM licenses"

ここが埋まっていれば sk_live_ が正しい。空なら401で弾かれている、つまり取り違えている。キーが発行されたことだけで安心しない。

本番化のときは自分で1件買って、この値を確認した。前回テストモードで同じ形の失敗を踏んでいたおかげで、「どこを見れば分かるか」を事前に1つに絞れていたのが大きい。逆に言えば、絞れていなければ「キーが出たから成功」で通過していた。

罠2: urllib の既定 User-Agent が Cloudflare に弾かれる

こちらは金銭的な被害こそ無いが、影響範囲が最大になる。全ユーザーの登録が通らなくなる。

アプリからライセンス登録すると「ライセンスサーバがエラーを返しました (403)」とだけ出る。Worker のログを見ても、そもそもリクエストが到達していない。D1 も正常、ローカルの curl では通る。

犯人は Python の urllib が既定で名乗る Python-urllib/3.x だった。これを Cloudflare が403(error code 1010)で弾いていた。返ってくるのが JSON ではなく HTML のエラーページなので、アプリ側からは何も読み取れない。

サーバ側は何も悪くない、というのがこの罠の性質を表している。自分のコードをいくら読んでも原因は出てこない。

切り分けは UA を変えて叩き比べれば一目でつく。

curl -s -o /dev/null -w "%{http_code}\n" -A "Python-urllib/3.10" -X POST \
  https://<worker>/verify -H 'Content-Type: application/json' -d '{"key":"...","device":"x"}'
# 403 なら UA が原因

対処は明示的に名乗るだけ。ただしこれは「消すと全ユーザーの登録が通らなくなる1行」なので、消さないことをドキュメントに理由つきで書いた。

罠3: PyInstaller の --collect-submodules が黙って0件を返す

ビルドは成功する。出来上がった exe は起動した瞬間に落ちる。

ModuleNotFoundError: No module named 'kawarin.gui'
  File "kawarin\hotreload.py", line 96, in _make_window

GUI を importlib.import_module() で動的に読んでいるので、静的解析では追えない。それは分かっていたので --collect-submodules kawarin を指定していた。

ところがこの収集は隔離された子プロセスで走り、その sys.path にリポジトリ直下が入らない。パッケージをインストールせずソースを置いているだけの構成だと、子プロセスからは見えない。そして見つからなかったとき、collect_submodules() は警告も出さずに空リストを返す。

PYTHONPATH=. を渡したら 2 → 32 モジュールになった。ビルドログには最後まで何も出ない。

人の目では防げないので、ビルド後に成果物の TOC を検査して主要モジュールが入っているか数えるステップを CI に足した。次に誰かが PYTHONPATH を消しても、そこで落ちる。

罠4: 非公開リポジトリの Releases は匿名だと404

タグを push してビルドが通り、Release も作られる。LP のダウンロードボタンを押すと404。

リポジトリが非公開だと、Releases の資産は匿名では取得できない。開発者はログイン済みなので、自分でテストすると普通にダウンロードできてしまう。

配布の本体を R2 に移し、アップロード直後に匿名で HEAD を投げて 200 とサイズを確かめるステップを入れた。ここを自動で見ないと「ビルドは緑なのに LP のダウンロードが404」に気付けない。

4つに共通していたこと

並べると全部同じ形をしている。失敗が成功の顔をしている。

表向き 実態
secret 取り違え キー発行成功 期限の入っていないライセンス
urllib の UA サーバがエラーを返した リクエストが到達していない
--collect-submodules ビルド成功 中身が空の exe
Releases の404 リリース成功 誰もダウンロードできない

普通のバグはテストを書けば落ちる。この4つはどれも、自分の環境・自分のアカウントで試すと通ってしまう。開発者だけが踏まない。

だから対処の型も同じになった。

通しで1件、本物を買う。 テストモードでは罠1は絶対に見つからない。本番の secret でしか起きないからだ。

決め手になる値を1つ先に決めておく。「キーが出た」ではなく「expires が入っている」。曖昧な成功判定は、曖昧なまま通過する。

匿名・外部から確認するステップを自動化する。ログイン済みの自分は、購入者と違うものを見ている。

おまけ: テストで作ったライセンスは本番の台帳に残る

D1 はテストモードと本番モードで共有される。テストモードの購入で発行したライセンスは、一円も払われていないのに本番の台帳に active で残る。

本番化のときに落とした。ただし削除ではなく失効にしている。

UPDATE licenses SET status='canceled' WHERE key='<テストで出たキー>';

取引記録として後から追えるほうがいいので。ちなみに自分の PC のアプリがそのテストキーで動いていて、失効させたら無料版に戻る、というオチも付いた。

次の一歩

これから同じことをするなら、コードを書く前に1つだけ決めておくといい。「この値が入っていれば成功」と言い切れる1箇所はどこか。

自分の場合は D1 の expires だった。ここを先に決めていたから、本番決済を1件通した時点で secret の正しさが確定した。決めていなければ「キーが出たので大丈夫そう」で終わっていたはずで、たぶん最初の更新日まで気付かなかった。

そのうえで、まだ確かめていないことも正直に書き残している。時間が経たないと試せないものだ。

  • 更新(2か月目の課金)。customer.subscription.updatedexpires が伸びる想定だが、1か月経つまで分からない
  • 年額の途中解約。Stripe は cancel_at_period_end を立てるだけで、deleted が飛ぶのは1年後
  • 消費税。外税で売る形にしてあるが、Stripe Tax は未設定

「確かめていない」と書いておくと、次に触るときの自分が疑ってくれる。緑のログを信用しすぎて4回止まった身としては、これも対策のうちだと思っている。