~/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_KEY に whsec_...(Webhook の署名シークレット)を入れていたこと。すると何が起きるか。
- 署名検証は別の環境変数(
STRIPE_WEBHOOK_SECRET)を見るので、そちらが正しければ通る - webhook は 200 を返し、ライセンスキーも発行される
- しかし Stripe API 呼び出しが 401 になり、サブスクリプションの情報が取れない
- 結果、
expiresが空のライセンスができる - 空の期限は「期限なし」として扱われ、時間では失効しなくなる
解約そのものは止まる。Stripe から届くイベントで status が canceled に変わり、次の再確認で弾かれるからだ。消えるのは期限による自然失効のほうで、これが効かないと、サーバに再確認が届かない限り手元の控えは 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.updatedでexpiresが伸びる想定だが、1か月経つまで分からない - 年額の途中解約。Stripe は
cancel_at_period_endを立てるだけで、deletedが飛ぶのは1年後 - 消費税。外税で売る形にしてあるが、Stripe Tax は未設定
「確かめていない」と書いておくと、次に触るときの自分が疑ってくれる。緑のログを信用しすぎて4回止まった身としては、これも対策のうちだと思っている。