I spent a full day building the one button that makes it easy for people to stop paying me. On purpose.
Here's why that's not as dumb as it sounds.
NotebookBloom, my NotebookLM extension, has a Pro tier. Payment goes through a Stripe Payment Link — you click, you pay, you're Pro. Clean. Done in an afternoon. And for a while I thought that was the whole billing story.
It isn't. A Payment Link is a one-way door. It's great at letting money in. It does nothing about letting people out. There was no cancel screen anywhere in my product. If you subscribed and then wanted to stop, your options were: email me and wait, or... call your bank.
That second option is the one that should scare every solo founder.
When a customer can't find how to cancel, they don't shrug and keep paying. They dispute the charge — a chargeback. And chargebacks don't just cost you that one payment plus a fee. Stripe watches your chargeback rate like a hawk. Cross a threshold and they can freeze or close your account. One angry user who couldn't find a cancel button is an annoyance. A pattern of them is an existential threat to the thing collecting all your revenue.
So the cancel button isn't a courtesy to users. It's insurance on my own business.
The clean way to do it is Stripe's Customer Portal — a hosted page where a customer can cancel, change their card, or download invoices, none of which I have to build. I just have to send the right customer to the right portal session.
That "right customer" part is where the actual work was. The portal link has to be generated per-customer, server-side, and I had to make sure user A can't open user B's billing portal. My flow: the extension sends the user's Google token to my Cloudflare Worker. The worker verifies that token with Google to get the real email — the client never just says "I'm bob@gmail" and gets believed, because that's how you'd let anyone cancel anyone's subscription. With the verified email, the worker looks up the Stripe customer id, asks Stripe for a one-time portal session, and hands the URL back. The extension opens it in a new tab. One "Manage subscription" button in settings, and everything else is Stripe's problem.
The loop closes on its own: user cancels in the portal, Stripe fires a subscription.deleted webhook, my worker flips their record to inactive, and the next time the extension revalidates, they're back to free. I don't touch anything.
There was one detail I got wrong at first, mentally. I assumed "cancel" meant "Pro turns off now." It doesn't — Stripe defaults to canceling at the end of the paid period. Which is correct! They paid for the month, they get the month. So a canceled-but-still-active subscription is a real state I had to handle: the extension now shows "Pro access until [date] — won't renew" instead of just yanking it. Small thing, but it's the difference between feeling respected and feeling robbed.
The reframe I'm keeping: the exit is part of the product. I spent the first afternoon on the part that takes money and thought I was done. The part that lets people leave gracefully turned out to matter more — to my Stripe account's health, and to whether anyone trusts me enough to subscribe in the first place.
Did anyone else find out the hard way that "accept payments" and "handle subscriptions" are two completely different amounts of work?
— building NotebookBloom in public, #14
Top comments (4)
Framing the cancel button as protection for your own Stripe account rather than a courtesy is the right instinct, and showing "Pro access until [date], won't renew" is the bit most people skip on the first pass.
Worth stress-testing one thing though: your revocation path hangs entirely on
customer.subscription.deleted, which is the voluntary churn path. The bigger bucket for a solo product is cards expiring or failing at renewal, and that doesn't go neardeletedfor a while. The subscription sits inpast_duewhile Stripe works through its retry schedule, and what happens once the retries are exhausted is a setting in your Stripe subscription settings. If yours is set to cancel, your existing worker code handles it fine. If it's set to mark the subscription unpaid or just leave itpast_due,deletednever fires and someone who stopped paying keeps Pro indefinitely, because nothing ever tells the worker to flip the record.Worth checking which one your account is on, and separately deciding whether Pro stays live during the retry window, which is more a product call than a technical one.
you've now caught the same nerve twice from two directions, and it's the same nerve. a week or so back you nailed that a card mid-retry reads past_due, not active — and my entitlement check was quietly narrower than that, so i widened it: Pro now stays live for active, trialing, and past_due, and only incomplete / incomplete_expired / unpaid / canceled fall out. so the "does Pro stay live during the retry window" question you're raising here, i actually answered yes to last week, deliberately — for a $2.99 tool i'd rather keep serving someone whose bank is being slow than punish them for it.
but you just found the other half of that same coin and it's the half i hadn't checked. keeping Pro live through past_due is a decision i made on purpose. what happens when the retries run out is a decision i've apparently been letting Stripe make for me. if my account is set to leave failed subscriptions sitting in past_due (or flip them to unpaid) instead of canceling them, then deleted never fires, my worker never hears about it, and — because i deliberately whitelist past_due as Pro — that user keeps Pro forever. i basically built a permanent free ride for anyone whose card fails, and wired it that way on purpose without noticing the tail.
one thing that softens it a little: i don't actually hang everything on the webhook anymore. i reconcile at read time — every /status call pulls the customer's latest subscription straight from Stripe and rewrites the record, so even if deleted never fires i'd eventually read the real status. but that only saves me if the real status ever becomes canceled/unpaid. if the account setting leaves it parked in past_due indefinitely, read-time reconciliation faithfully reads "past_due" forever and my own whitelist says "still Pro." the reconciliation doesn't rescue me from a status that never changes.
so the actual fix is the boring one you pointed straight at: go into Stripe's subscription settings and confirm what happens after retries are exhausted — set it to cancel, or add unpaid to the "not Pro" side of my whitelist, or ideally both. that's a config check plus one array edit, not architecture. thanks for turning that over — you've now improved this exact flow twice.
I appreciate how you highlighted the importance of implementing a seamless cancellation process, which is often overlooked when building payment systems. The use of Stripe's Customer Portal is a great solution, and I'm impressed by the effort you put into ensuring the security of the portal link generation process. One thing that caught my attention was the distinction between "cancel" and "immediate Pro access removal" - it's a subtle but crucial detail that can greatly impact the user experience. Have you considered adding any notifications or follow-up emails to users after they cancel their subscription to gather feedback and improve the overall experience?
thanks Luis — and you zeroed in on the part i'm proudest of getting right, the "cancel now vs. Pro until the period ends" distinction. it's tiny in code and it's the whole difference between someone feeling respected and someone feeling robbed of the month they already paid for.
the follow-up-email idea is a good one, and here's the honest reason i can't just do it: i deliberately don't have an email list, or really any user record to email. the app stores its data in the user's own Google Drive, not on my servers, and the only email i ever touch is the verified Google address i use at the moment of a billing check — i don't retain it as a marketing contact. so "email them after they cancel" would mean standing up exactly the kind of user data pipeline i built the whole product to avoid. i'd rather keep the promise than send the survey.
what i can do without breaking that, and am now thinking about because of your comment: two lighter touches. one, that "Pro until [date], won't renew" screen is itself a live window — someone who cancels but still has three weeks of Pro is the most honest person to ask "what made you leave," right there in the product, no email needed. two, when they actually drop back to free, a single quiet in-app question instead of a mailer. both keep the feedback loop but stay inside the extension, where i already have permission to be. Stripe's portal also has a built-in cancellation-reason prompt i haven't turned on yet — that's probably the cheapest first step, since it collects the "why" without me touching identity at all.
so: no emails, on purpose — but you've convinced me the exit shouldn't be silent, and there are ways to ask that don't require me to become a company that keeps a mailing list. appreciate the nudge.