π³ Get paid without leaking money
A free tier first, then Stripe's hosted Checkout and Portal, entitlements the server checks, and signed webhooks, proven end to end in test mode.
You'll leave with
- A free tier that finishes the core job, with a limit only your server enforces
- A test-mode checkout where only a verified webhook can grant Pro
- A pricing page and one upgrade button that can't double-charge
This is the lesson where a shortcut costs real money, so it's the one place I stop being lazy. You'll design what's free first, then let Stripe handle every card while your server, never the browser, decides who paid. By the end, a test purchase flips a test user to Pro within seconds. Charging later? Do step 1 today; the rest can wait.
What are Stripe, Pro and test mode?
Stripe is a payment company: it takes the card and pays you. Your server talks to it through its API, web addresses a program calls to ask for things. "Pro" is my name for an app's paid tier. Test mode, which Stripe calls a sandbox, is a practice copy of your account: fake cards, no real money.
π Honest status: billing for my apps at traqqit.com is still in Stripe test mode.
You'll need Accounts without building auth and Email that arrives (people who pay must be able to reset a password). Server code goes in your PocketBase hooks file, pb_hooks/main.pb.js: JavaScript that PocketBase (v0.23 or later) runs on the server. Claude Code, the AI assistant in VS Code (your code editor), writes it; you learn to read the lines that matter.
Design the free tier first
The free tier must finish the core job. Grease, my strength-practice app, first locked logging behind Pro. I reversed it: a free tier that can't complete the loop looks broken, not generous. Free users now plan and track one workout completely; Pro adds unlimited workouts, history charts, auto-progression and export. Handstand gives away all six stages up to a freestanding handstand; Pro adds history, custom settings and a bonus stage.
- Free does the core job end to end, for one item. Write the job in one sentence: "get a daily plan and log it".
- Pro is more, deeper or handier: unlimited items, history and charts, export.
- Safety stays free forever. Grease's intensity dial, which keeps you below failure, is never gated.
- Alert apps sell speed. On one of my side projects, free gets one alert on the 30-minute check; Pro gets unlimited alerts on a 5-minute check of the most-wanted items.
Then ask Claude to write it into NOTES.md, the notes file it reads each session: "Read my app and add a Free vs Pro section to NOTES.md: the core job in one sentence, what free does end to end, what Pro adds, what's never gated, and the one daily limit my server will enforce." Open NOTES.md afterwards and check that free never stops short of the core job.
Ship the shape before Stripe
You don't need Stripe on day one. Ship the limit, a visible "Pro is coming" line, and a manual Pro flag you switch on yourself in PocketBase's admin dashboard (the superuser screens from Accounts). One line of server code then answers "is this user Pro?", and billing changes only that line.
π Acento, my Spanish trainer, launched accounts with 10 free reviews a day and a hand-set Pro flag. When Stripe arrived, Pro came from the user's paid entitlements instead, and the limit logic stayed exactly the same.
What's an entitlement?
A named thing a customer has paid for, like myapp_pro. Stripe keeps each customer's active list; your server copies it onto the user's record and checks it before doing anything paid.
The limit lives on the server
Anyone can skip a limit checked only in the browser, so a route on your server (an address it answers, here /api/myapp/use) counts today's uses. At the limit it refuses with HTTP status 402 and the error daily_limit; every success returns the uses left, so the page can show "7 left today".
What are a request, a response, a header and a status code?
A request is a message a browser or program sends to a server; the response is the reply, often JSON (data as plain text). Both carry headers, labeled lines like "who's logged in". Each response starts with a status code: 200 OK, 400 bad request, 401 log in first, 402 payment required, 403 forbidden, 404 not found.
EXAMPLE β YOU ASK CLAUDE CODE
remaining going 9 down to 0; the 11th got 402 daily_limit. A signup sending myapp_manual_pro: true saved false. Test user deleted.β³ Bash Β· ssh root@YOUR_VPS_IP 'mkdir -p /root/backups && cp /root/pb/pb_hooks/main.pb.js /root/backups/main.pb.js.$(date +%F-%H%M)'β³ Edit Β· pb_hooks/main.pb.jsβ³ Bash Β· curl β¦ sign up with myapp_manual_pro: true, read the record backβ³ Bash Β· curl -X POST https://myapp.yourdomain.com/api/myapp/use (Γ11, as a test user), then DELETE the test userWhat's happening underneath: the heart of the route.
routerAdd("POST", "/api/myapp/use", (e) => {
const LIMIT = 10;
const u = e.app.findRecordById("users", e.auth.id);
const pro = u.getBool("myapp_manual_pro");
const day = new Date().toISOString().slice(0, 10);
let count = u.getString("myapp_day") === day ? u.getInt("myapp_count") : 0;
if (!pro && count >= LIMIT) {
return e.json(402, { error: "daily_limit", remaining: 0 });
}
count++;
u.set("myapp_day", day);
u.set("myapp_count", count);
e.app.save(u);
// ...do the thing you're metering here...
return e.json(200, { pro: pro, remaining: pro ? null : LIMIT - count });
}, $apis.requireAuth("users"));
requireAuth turns logged-out requests away with 401. The page shows the upgrade banner only on 402; any other failure, including no connection, says "Couldn't save. Check your connection." Guests get a counter in localStorage (storage inside their browser): easy to reset, and fine as a nudge to sign up.
Every hooks change here follows Accounts' routine and paths, which the chats below abbreviate: dated backup in /root/backups, code added at the end, a node --check syntax check, upload; PocketBase reloads it within seconds. To undo, on the server (ssh root@YOUR_VPS_IP), copy the newest backup back, its name in place of YYYY-MM-DD-HHMM (ls /root/backups lists them):
cp /root/backups/main.pb.js.YYYY-MM-DD-HHMM /root/pb/pb_hooks/main.pb.js
Two more traps. toISOString() gives the date in UTC (the world's reference clock), and midnight UTC is 5 pm in Phoenix: say "resets at 00:00 UTC", or use the user's time zone. And a CSS lock (an overlay drawn with the page's styling) over a feature that runs entirely in the browser comes off in DevTools (the browser's developer tools: F12, or Cmd+Option+I on a Mac). Compute or serve anything valuable on the server, after the check.
No fake unlocks, no fake prices
Grease and Handstand went live before their payment products existed, with buttons saying "Pro is coming online shortly" and Pro locked. Never fake-unlock: if checkout errors or isn't set up, keep Pro locked, and never set Pro to true in browser code as a fallback. Never fake a price: show only prices that exist in Stripe, and a crossed-out "was" price only if you really charged it. An early sales page of mine showed a "was" price I never charged. It's gone: in many places that counts as deceptive pricing.
π The classic leak: an "unlock on error" shortcut from testing reaches production, and everyone gets Pro free. Before you ship, ask Claude to search every file the browser loads for code that sets a paid flag to true or unlocks when a request fails. The right answer is none.
The trust boundary in four rules
Lazy stops at the trust boundary: the line between your server, which you control, and every visitor's browser, which you don't.
What are hosted Checkout, the Customer Portal and a webhook?
Checkout is Stripe's own payment page: you send the buyer there and Stripe takes the card. The Customer Portal is Stripe's page where customers cancel, change cards and get invoices. A webhook runs the other way: an address on your server that Stripe calls when something happens, like a payment.
- The secret key stays on the server, in an env file (a small text file of settings a program reads when it starts). Hosted Checkout and Portal mean you never touch a card number.
- Pro is decided on the server from what Stripe granted, never from a flag the browser can change.
- Only the webhook writes a user's entitlements. Users can read that field but never edit it.
- The page only shows what the server says. Anything truly valuable is computed or served after the check.
Rule 3 leans on Accounts: the users collection's update rule (who may edit a record through the API) stays locked, and hooks write paid fields with e.app.save(), which skips the rules. Accounts already added entitlements, myapp_day and myapp_count; add stripe_customer (text) the same way, with USER_FIELDS in setup-pb.mjs, before the hook that reads it ships. Then make your signup hook reset stripe_customer, myapp_manual_pro, entitlements and both counters, so a crafted signup can't arrive paid or with uses to spare.
π A leaked secret key can charge, refund and read your customers. If one ever lands in a chat, commit or web page, roll it (Stripe's word for replacing it) on the API keys page at once.
Hosted Checkout and Portal
Create a free Stripe account at stripe.com and build everything in its sandbox (test mode). You don't need business details or a bank account until you go live. Pay with test card 4242 4242 4242 4242, any future date and any CVC.
In the dashboard, check for the Sandbox or Test mode label at the top, then open Developers β API keys (Stripe moves menus; search "API keys" if it isn't there). The secret key starts with sk_test_ (you may have to click to reveal it). Never paste it into Claude's chat. In a terminal of your own, log in with ssh root@YOUR_VPS_IP and paste these two lines; the second silently waits for you to paste the key and press Enter.
mkdir -p /root/secrets && touch /root/secrets/pb.env && chmod 600 /root/secrets/pb.env && sed -i '/^STRIPE_SECRET_KEY=/d' /root/secrets/pb.env
read -rsp 'Paste the secret key, then press Enter: ' KEY && echo "STRIPE_SECRET_KEY=$KEY" >> /root/secrets/pb.env && unset KEY
Check it without printing the key:
grep -c '^STRIPE_SECRET_KEY=sk_test_' /root/secrets/pb.env
1 means one test key line, in a file only root can read, outside any web folder; re-running is safe. A container (the sealed box PocketBase runs in) reads its env file only when it's created, so docker restart won't pick this up:
EXAMPLE β YOU ASK CLAUDE CODE
sk_test_ (first 8 characters only), and /api/health answers 200.β³ Bash Β· ssh β¦ 'docker inspect --format "{{json .HostConfig}}" pb'β³ Bash Β· ssh β¦ 'docker rm -f pb && docker run β¦ --env-file /root/secrets/pb.env β¦'β³ Bash Β· ssh β¦ 'docker exec pb printenv STRIPE_SECRET_KEY | cut -c1-8'β³ Bash Β· curl -s https://accounts.yourdomain.com/api/healthThe checkout route
The browser asks your server for a checkout by plan key. The server:
- accepts only allowlisted keys (a fixed list), answering 400 to anything else;
- builds the success and cancel addresses from the key, never from the request, so it can't become an open redirect (a link on your domain that bounces people to an attacker's site);
- creates the Stripe customer once, saving its id on the user and the user's id in its metadata (labels on a Stripe object);
- puts the 7-day trial on the Checkout session (one visit to Stripe's payment page), not the price, so changing trials never means new prices;
- returns only the stripe.com link.
EXAMPLE β YOU ASK CLAUDE CODE
What's happening underneath:
routerAdd("POST", "/api/myapp/checkout", (e) => {
const ALLOWED = ["myapp_pro_monthly", "myapp_pro_yearly"];
const plan = e.requestInfo().body.plan;
if (!ALLOWED.includes(plan)) return e.json(400, { error: "unknown_plan" });
const u = e.app.findRecordById("users", e.auth.id);
const ents = JSON.parse(u.getString("entitlements") || "null") || [];
if (ents.length) return e.json(409, { error: "already_subscribed" });
const stripe = (path, form) => {
const res = $http.send({
url: "https://api.stripe.com/v1/" + path,
method: form ? "POST" : "GET",
body: form || "",
headers: {
"Authorization": "Bearer " + $os.getenv("STRIPE_SECRET_KEY"),
"Content-Type": "application/x-www-form-urlencoded",
},
});
if (res.statusCode >= 300) throw new Error("Stripe said " + res.statusCode);
return res.json;
};
let cus = u.getString("stripe_customer");
if (!cus) {
cus = stripe("customers", "email=" + encodeURIComponent(u.email()) + "&metadata[pb_user_id]=" + u.id).id;
u.set("stripe_customer", cus);
e.app.save(u);
}
const price = stripe("prices?lookup_keys[]=" + plan).data[0].id;
const back = "https://" + plan.split("_pro")[0] + ".yourdomain.com/?checkout=";
const session = stripe("checkout/sessions", [
"mode=subscription",
"customer=" + cus,
"line_items[0][price]=" + price,
"line_items[0][quantity]=1",
"subscription_data[trial_period_days]=7",
"success_url=" + encodeURIComponent(back + "success"),
"cancel_url=" + encodeURIComponent(back + "cancel"),
].join("&"));
return e.json(200, { url: session.url });
}, $apis.requireAuth("users"));
Replace myapp and yourdomain.com with yours. The 409 is a second guard against double charges. The portal route is the same shape: a fixed map of return addresses, then a billing_portal/sessions link for the logged-in user's own saved stripe_customer, never one from the request. That Portal handles cancelling, cards and invoices, so my fleet has zero billing screens. In the dashboard, search "customer portal", leave cancelling on and press Save: until it's saved once, Stripe refuses portal links. Plan switching waits for step 3.
Check the logged-out case with curl, a command that sends one request and prints the reply (in PowerShell, type curl.exe). Replace the address; the first line should show 401, like HTTP/2 401:
curl -si -X POST https://myapp.yourdomain.com/api/myapp/checkout
π¦₯ Skipped on purpose: card forms, Stripe's browser script, a Stripe code library (plain HTTPS calls do the job) and every billing screen.
Entitlements and lookup keys
Use one normal Stripe account for all your apps; Stripe Connect is for marketplaces that pay other people.
What are products, prices, features and lookup keys?
A product is what you sell ("myapp Pro"). A price is one way to pay for it, monthly or yearly, in cents. A feature is an entitlement name, like myapp_pro, attached to products: buy one, get the feature. A lookup key is a readable name for a price, like myapp_pro_monthly, so your code never stores Stripe's long ID strings.
Lookup keys let the same code run in test and live mode: IDs differ between the two, names don't. Selling a bundle (one subscription for several apps, like my Traqqit Max)? Attach myapp_pro to the app's product and the bundle, plus a bundle-only feature, bundle_all, to tell bundle buyers from single-app ones. Each app checks only its own name.
Build it with a script, not dashboard clicks, which leave no record and let test and live drift apart. Make it idempotent (safe to run twice): it finds each object by its key and creates only what's missing.
EXAMPLE β YOU ASK CLAUDE CODE
Replace PRICE_IN_CENTS with your monthly price in cents (900 is $9.00); Python comes with Ubuntu. The second run should print something like:
Now reopen the portal settings, turn on Switch plan, add your product with both prices and press Save.
β οΈ Test plan switching yourself, as a customer (step 6 opens the portal). On my account, pinned to Stripe API version 2025-03-31.basil, the portal's list of products you can switch between was silently not saved.
Prices can't be edited
To reprice, create a new price, move the lookup key onto it with transfer_lookup_key=true, and archive the old one (active=false). New buyers get the new price; existing subscribers keep theirs. Point the Portal at the new price, update your page, and run a test checkout.
π I repriced Traqqit Max after launch, per-app Pro up and the bundle down, so the bundle cost less than two apps. "Change the number and re-run setup" would have silently kept the old amount: find-or-create finds the old price by its key. Repricing is its own step.
π Adding Grease to billing came to about a dozen safe-to-rerun lines, prepared locally and deliberately left unapplied until there was a clear go-ahead to touch the shared billing server, where one mistake breaks every app's payments. Such a change only adds (look entries up by key, not position), goes in after a backup, and ends with a test of an existing app's checkout.
Webhooks you can trust
Your webhook address is public, so anyone can send it a fake "payment succeeded". Stripe signs every real event, and only a request that passes your check changes anything, so the route has no login check: the signature is the check.
What are a signature, an HMAC, the raw body and constant time?
Stripe and your server share a webhook secret. For each event, Stripe computes an HMAC over the raw body (the request's exact bytes as they arrived): a keyed fingerprint, made with the SHA-256 recipe, that only a holder of the secret can produce. A constant-time comparison takes equally long wherever two strings differ, so timing gives an attacker nothing.
- Read the raw body before any JSON parsing.
- Take the timestamp
tand everyv1signature from theStripe-Signatureheader (t=1700000000,v1=abcβ¦). - Compute HMAC-SHA256 of
t, a dot and the raw body with the webhook secret, as hex (hexadecimal text). Accept only a constant-time match with av1, and only iftis within five minutes of now, so a captured request can't be replayed.
EXAMPLE β YOU ASK CLAUDE CODE
whsec_ (6 characters shown); an unsigned POST gets 400. The real proof is step 6.β³ Edit Β· pb_hooks/main.pb.jsβ³ Bash Β· scp webhook_setup.py root@YOUR_VPS_IP:/root/billing/ && ssh β¦ 'python3 /root/billing/webhook_setup.py'β³ Bash Β· ssh β¦ 'docker exec pb printenv STRIPE_WEBHOOK_SECRET | cut -c1-6'β³ Bash Β· curl -si -X POST https://accounts.yourdomain.com/webhooks/stripe -d "{}"The five events: checkout.session.completed, customer.subscription.created, customer.subscription.updated, customer.subscription.deleted and entitlements.active_entitlement_summary.updated. The script recreates the endpoint (Stripe's record of your webhook address) because the API returns its signing secret only once, at creation. Replace accounts.yourdomain.com with your PocketBase address from Accounts.
What's happening underneath: the core of the route, without the fallback.
routerAdd("POST", "/webhooks/stripe", (e) => {
const secret = $os.getenv("STRIPE_WEBHOOK_SECRET");
if (!secret) return e.json(500, { error: "no_webhook_secret" });
const raw = toString(e.request.body);
let t = "";
const sigs = [];
(e.request.header.get("Stripe-Signature") || "").split(",").forEach((part) => {
const kv = part.split("=");
if (kv[0] === "t") t = kv[1];
if (kv[0] === "v1") sigs.push(kv[1]);
});
const expected = $security.hs256(t + "." + raw, secret);
const fresh = Math.abs(Date.now() / 1000 - Number(t)) <= 300;
if (!fresh || !sigs.some((s) => $security.equal(s, expected))) {
return e.json(400, { error: "bad_signature" });
}
const cus = JSON.parse(raw).data.object.customer;
if (!cus) return e.json(200, { skipped: true });
const res = $http.send({
url: "https://api.stripe.com/v1/entitlements/active_entitlements?limit=100&customer=" + cus,
headers: { "Authorization": "Bearer " + $os.getenv("STRIPE_SECRET_KEY") },
});
if (res.statusCode !== 200) return e.json(502, { error: "stripe_unavailable" });
const keys = res.json.data.map((x) => x.lookup_key);
const u = e.app.findFirstRecordByData("users", "stripe_customer", cus);
u.set("entitlements", JSON.stringify(keys));
e.app.save(u);
return e.json(200, { ok: true });
});
Check the door yourself (replace the address; in PowerShell, curl.exe). The first line should show 400:
curl -si -X POST https://accounts.yourdomain.com/webhooks/stripe -d "{}"
Don't trust the event; re-read the truth
Webhooks can arrive twice or out of order. So the handler ignores what the event says, asks Stripe for the customer's active entitlements right now, and overwrites the user's copy. Repeats and out-of-order events all end at the same correct state, with no list of handled events to keep. Stripe stays the source of truth; your database is a fast copy.
β οΈ If every signature fails, it's usually the wrong secret (each endpoint has its own) or a body parsed and turned back into text before hashing: hash the exact raw bytes. If every event fails as too old, the server clock has drifted; timedatectl on the server should say System clock synchronized: yes.
- One-time grants are additive. Something sold once never appears in Stripe's active subscription entitlements, so the overwrite would erase it at the next subscription event. Add one-time keys only if missing, and make the overwrite keep them.
- The webhook takes a few seconds. On
?checkout=success, show "Confirming your upgradeβ¦", wait a moment and reload the user's record, retrying a few times.
Now flip the switch from step 1: ask Claude to replace the const pro line in /api/myapp/use with these two. Drop the manual flag once every hand-flagged user is dealt with (keep a list).
const ents = JSON.parse(u.getString("entitlements") || "null") || [];
const pro = ents.includes("myapp_pro") || u.getBool("myapp_manual_pro");
One button, a price that adds up
A naive "Buy" button lets someone who already pays start a second subscription and get charged twice. On Traqqit Max, every button runs through one function that refreshes the visitor's entitlements first, then decides:
| Who clicks | Where the button sends them |
|---|---|
| Logged out | Sign-up first |
| Logged in, no subscription | Checkout |
Has one app's Pro, not bundle_all | The Portal, to switch plans with proration (credit for the unused part of the current plan) |
Has bundle_all | The Portal, with a "You're on the bundle" banner |
EXAMPLE β YOU ASK CLAUDE CODE
Private window: Ctrl+Shift+N in Chrome or Edge, Cmd+Shift+N on a Mac. Every section points at that same button, the note must really be yours, and the math has to add up:
- The bundle costs less than two single apps, which is why I repriced Traqqit Max (step 3).
- Yearly is 10 Γ monthly, which explains itself as "2 months free".
- The crossed-out total is real: every app bought separately, at prices you actually charge.
- The app count appears everywhere: about six places on Traqqit Max (headline, subhead, crossed-out total, checklist, math, FAQ). Update them all when you add an app.
Mention the bundle the moment a free user runs out: Acento's "daily limit reached" banner links to Traqqit Max. Count those clicks (Know if it's working), and add the link only to apps that actually have a Pro tier; one with nothing behind it feels like spam.
Prove the money loop in test mode
Stripe tests its own card page; don't automate that part.
EXAMPLE β YOU ASK CLAUDE CODE
cs_test_. myapp_pro appeared about 2 s after you paid and was gone about 6 s after you cancelled. The user's own PATCH: 403. Logged out: 401. Test user deleted.β³ Bash Β· curl β¦ /api/myapp/checkout, β¦/portal, logged-out checkoutβ³ Bash Β· curl β¦ /api/collections/users/records/ID (poll, PATCH, DELETE)Your part: pay with 4242 4242 4242 4242, any future date, any CVC, and you land back on https://myapp.yourdomain.com/?checkout=success. In the Stripe dashboard, search the test user's email: the subscription says Trialing, with a $0.00 first invoice, because the trial lives on the session. Then cancel through the portal link, immediately for this test (a portal setting, or the subscription's page in the dashboard): a cancel at period end rightly keeps Pro until then.
π In my test runs, Pro appeared about 2 seconds after paying and cleared about 6 seconds after cancelling.
Going live is a script
Nothing from test mode exists in live mode: test IDs give 404 errors under a live key, so every product, price and webhook is created again. Thanks to lookup keys your code doesn't change; one script re-runs the setup:
- The live secret key goes on one line in a
chmod 600file on the server. - The script checks it works and backs up the test env file with a timestamp.
- It re-runs the setup and webhook scripts in live mode and recreates the container with the live env file.
- It sends an unsigned request to the webhook, expects a rejection, and prints the one-line rollback command.
- You confirm the account can take charges, buy once with a real card, and cancel.
A sandbox has no live mode at all: going live needs your real, activated Stripe account. First decide your refund policy and check sales-tax rules where you sell (they vary by place; ask an accountant). My own go-live script is written and hasn't run: my billing is still in test mode, which is why you'll see no revenue numbers here.
One-time products: the receipt is the login
A one-time product needs no database. This course's own one-time gate, proven in test mode before the course went free, sent buyers from Checkout to /members?session_id=β¦. The server fetched that session from Stripe (GET /v1/checkout/sessions/SESSION_ID?expand[]=line_items) and let the buyer in only if it was paid, a one-time payment, and held this product's exact price; skip the price check and a buyer of anything else on the account gets in. The link stays the way back in; later visits use a cookie (a value the browser stores and sends back with each request) holding an expiry time and its HMAC: unforgeable without the server's secret, with nothing to store. If a paid product later goes free, keep that gate behind one environment variable (one line in its env file) instead of deleting it.
Do it with Claude: the prompts
The prompts behind this lesson, in order; check each the way its step shows, and replace 10, myapp and yourdomain.com with yours.
Add a free limit of 10 uses per day, enforced by a server hook that returns 402 daily_limit plus the remaining count. Show Pro is coming instead of any checkout.
Add POST /api/myapp/checkout: logged-in users only, allowlisted plan keys, the customer created once, a 7-day trial on the session, success and cancel URLs built from the key, and only the session url returned. Add POST /api/myapp/portal with a fixed map of return URLs.
Write a setup script that finds or creates our products and prices by lookup_key in Stripe test mode. Safe to run twice.
Add a webhook route that verifies Stripe-Signature over the raw body, re-reads the customer's active entitlements and overwrites the user's entitlements field.
Route every upgrade button through one upgrade() function that refreshes the user first, so nobody can subscribe twice.
Run the full loop in test mode and time each step.
What's happening underneath: the browser only ever receives a stripe.com link; the key, the signature check and the decision all stay on your server. Push back when Claude's code puts a Stripe key in a file the browser loads, takes a price ID or return address from the request, parses the webhook body before checking its signature, puts a login check on the webhook route, trusts the order of events, or sets Pro in browser code.
Your turn
- Write your Free vs Pro line in NOTES.md: the core job, what's never gated, your one limit. Done when a free user finishes the core job for one item without hitting a lock.
- As a signed-in free test user, go past the limit: the server answers 402
daily_limitand the page shows the upgrade banner. With Wi-Fi off, you get a connection error instead. - Have Claude search every file the browser loads for code that sets Pro to true, unlocks on error, or holds a Stripe key: zero hits.
- Run the test-mode loop: Pro appears seconds after paying with the 4242 card and clears after an immediate cancel; an unsigned webhook POST gets 400, and the user's own PATCH of entitlements 403. Timings go in NOTES.md.
- Logged in as a subscribed test user, click every button on your pricing page: each opens the Portal, never a second Checkout. Every price on the page exists in Stripe.
Next, Build an email list (like this one): the one audience you own, and how a paid app tells its people what's new.