~/posts / silent-failures-in-monetization
Four traps with no error message, hit while monetizing with Stripe and Cloudflare
●
I put a price on a desktop tool I build on the side and ran the whole path from payment to first use. Nothing about it is technically hard. I still got stuck four times.
All four had the same character. The build is green. The webhook returns 200 and the tests pass. And yet the product is broken. Every one of them was that kind of failure. With the same setup, you will probably get stuck in the same places.
Context
- As of July 2026
- The app is Python 3.10 + PySide6, shipped for Windows as an exe built with PyInstaller
- Payment is a Stripe Payment Link (¥100/month, ¥1,000/year)
- Licences are issued by Cloudflare Workers + D1, which receive the Stripe webhook and mint a key
- The landing page is on Cloudflare Pages, the downloads live in R2
Trap 1: mix up the secrets and you issue licences with no expiry
Of the four this is the scariest. It is the only one that costs money.
Start with the symptoms. You buy, a licence key is issued, and it shows up correctly on the user’s screen. The webhook returns 200. A row lands in D1. Nothing looks wrong anywhere.
The cause was that STRIPE_SECRET_KEY held whsec_... — the webhook signing secret. Here is what that does.
- Signature verification reads a different environment variable (
STRIPE_WEBHOOK_SECRET), so as long as that one is right, it passes - The webhook returns 200 and the licence key is issued
- But the Stripe API call comes back 401, so the subscription details cannot be fetched
- The result is a licence with an empty
expires - An empty expiry is treated as “no expiry”, so it never lapses with time
Cancellation itself still works. The event from Stripe flips status to canceled, and the next re-check rejects it. What disappears is natural expiry by date, and without it the copy on the user’s machine stays Pro for as long as no re-check reaches the server. Billing and usable time quietly drift apart.
It looks completely normal to the buyer, so nobody reports it.
The one place to look
The decisive move is to look at expires and nothing else.
npx wrangler d1 execute kawarin-licenses --remote --command "SELECT key,status,expires FROM licenses"
If it is filled in, sk_live_ is correct. If it is empty you are being rejected with a 401 — the secrets are mixed up. Do not take comfort in the key having been issued.
When I went live I bought one myself and checked that value. I had hit the same shape of failure in test mode before, which is why I could narrow “where do I look to know” down to one place in advance. Put the other way: without that, “a key came out, so it worked” would have sailed straight through.
Trap 2: urllib’s default User-Agent gets blocked by Cloudflare
This one costs no money, but it has the widest blast radius: no user can register at all.
Registering a licence from the app shows only “the licence server returned an error (403)”. The Worker logs show that the request never arrived in the first place. D1 is fine and a local curl goes through.
The culprit was the Python-urllib/3.x that Python’s urllib announces by default. Cloudflare was blocking it with a 403 (error code 1010). What comes back is an HTML error page rather than JSON, so the app can read nothing out of it.
Nothing is wrong on the server side, and that captures the nature of this trap. No amount of reading your own code will produce the cause.
Narrowing it down takes one comparison with a different 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"}'
# a 403 means the UA is the cause
The fix is simply to announce yourself explicitly. But since this is “the one line that stops every user from registering if you delete it”, I wrote down in the docs, with the reason, that it must not be removed.
Trap 3: PyInstaller’s --collect-submodules silently returns nothing
The build succeeds. The resulting exe dies the instant it starts.
ModuleNotFoundError: No module named 'kawarin.gui'
File "kawarin\hotreload.py", line 96, in _make_window
The GUI is loaded dynamically through importlib.import_module(), so static analysis cannot follow it. I knew that, which is why --collect-submodules kawarin was there.
The catch is that the collection runs in an isolated child process whose sys.path does not include the repository root. With a layout where the package is not installed and the source simply sits there, the child process cannot see it. And when it finds nothing, collect_submodules() returns an empty list without so much as a warning.
Passing PYTHONPATH=. took it from 2 to 32 modules. The build log says nothing about any of this, start to finish.
Human eyes cannot catch this, so I added a CI step that inspects the artifact’s TOC after the build and counts whether the main modules are in there. The next time someone drops PYTHONPATH, it fails right there.
Trap 4: Releases on a private repository are a 404 for anonymous visitors
You push a tag, the build passes, the Release is created. Press the download button on the landing page and you get a 404.
When the repository is private, Release assets cannot be fetched anonymously. The developer is logged in, so testing it yourself downloads just fine.
I moved the actual distribution to R2 and added a step that, right after upload, sends an anonymous HEAD and checks for a 200 and the file size. Without checking that automatically, “the build is green but the download on the landing page 404s” is invisible.
What all four had in common
Lined up, they all have the same shape. The failure wears the face of success.
| Trap | On the surface | In reality |
|---|---|---|
| Mixed-up secret | Key issued successfully | A licence with no expiry |
| urllib’s UA | The server returned an error | The request never arrived |
--collect-submodules |
Build succeeded | An exe with nothing inside |
| 404 on Releases | Release succeeded | Nobody can download it |
An ordinary bug falls out when you write a test. All four of these pass when you try them in your own environment, with your own account. The developer is the one person who does not hit them.
So the countermeasures came out the same shape too.
Buy one real thing, end to end. Test mode will never surface trap 1, because it only happens with the production secret.
Decide on one decisive value up front. Not “a key came out” but “expires is filled in”. A vague definition of success passes vaguely.
Automate a step that checks from outside, anonymously. Logged in, you are looking at something different from what your buyers see.
Bonus: licences made while testing stay in the production ledger
D1 is shared between test mode and live mode. A licence issued by a test-mode purchase stays in the production ledger as active, without a single yen having been paid.
I cleaned those up when going live — by revoking rather than deleting.
UPDATE licenses SET status='canceled' WHERE key='<the key from testing>';
It is better to be able to trace the transaction record afterwards. Incidentally, the app on my own PC was running on that test key, so revoking it dropped me back to the free version. A fitting punchline.
The next step
If you are about to do the same thing, decide one thing before writing any code. Which single place can you point at and say “if this value is filled in, it worked”?
For me it was expires in D1. Because I had settled that first, one real payment was enough to confirm the secret was right. Without it I would have stopped at “a key came out, looks fine”, and probably would not have noticed until the first renewal date.
On top of that, I am keeping an honest record of what I have not verified yet. These are the ones that need time to pass.
- Renewal (the second month’s charge).
customer.subscription.updatedis expected to extendexpires, but I will not know for a month - Cancelling an annual plan mid-term. Stripe only sets
cancel_at_period_end; thedeletedevent arrives a year later - Consumption tax. It is sold tax-exclusive, but Stripe Tax is not configured
Writing down “not verified” makes future me suspicious the next time I touch it. As someone who got stuck four times by trusting green logs too much, I count that as part of the countermeasures.