Ship Data Apps with AI

πŸ‘₯ Users & moneyΒ·Lesson 19 of 26Β·~30 minΒ·Intermediate

πŸ’³ 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.

1

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

Claude Code
YouAdd a free limit of 10 uses per day, enforced by a server hook that returns 402 daily_limit plus the remaining count. For now Pro comes from a true/false field myapp_manual_pro on users, and my signup hook must reset it to false. Show Pro is coming instead of any checkout.
ClaudeDone, after a dated backup. A test user's first 10 uses returned 200, 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 user

What'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.

  1. 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.
  2. Pro is decided on the server from what Stripe granted, never from a flag the browser can change.
  3. Only the webhook writes a user's entitlements. Users can read that field but never edit it.
  4. 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.

2

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

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

Claude Code
YouRecreate my pb container with its current settings plus --env-file /root/secrets/pb.env. Never print its environment, where the key lives; prove the key arrived another way.
ClaudeRecreated; data and hooks live in mounted folders, so they're untouched. Inside, STRIPE_SECRET_KEY starts with 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/health

The checkout route

The browser asks your server for a checkout by plan key. The server:

  1. accepts only allowlisted keys (a fixed list), answering 400 to anything else;
  2. 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);
  3. creates the Stripe customer once, saving its id on the user and the user's id in its metadata (labels on a Stripe object);
  4. 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;
  5. returns only the stripe.com link.

EXAMPLE β€” YOU ASK CLAUDE CODE

Claude Code
YouAdd POST /api/myapp/checkout to the hooks file following those five rules, logged-in users only, refusing with 409 anyone who already has an entitlement. Add POST /api/myapp/portal too: logged in, return address from a fixed map.
ClaudeBoth added. Logged out, both return 401; as a test user, a made-up plan returns 400. Real plans work once step 3 creates the prices.↳ Edit Β· pb_hooks/main.pb.js↳ Bash Β· curl -si -X POST …/api/myapp/checkout and …/portal (logged out, then as a test user)

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.

3

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

Claude Code
YouWrite a setup script that finds or creates our products and prices by lookup_key in Stripe test mode. Safe to run twice. Python standard library, at /root/billing/stripe_setup.py, key read from /root/secrets/pb.env, refusing a live key. Product "myapp Pro" (metadata app=myapp), prices myapp_pro_monthly at PRICE_IN_CENTS and myapp_pro_yearly at ten times that, feature myapp_pro attached. Run it twice.
ClaudeThe first run created all four; the second created nothing (below). Check Product catalog in your dashboard: one product, two prices.↳ Write Β· stripe_setup.py↳ Bash Β· scp stripe_setup.py root@YOUR_VPS_IP:/root/billing/ && ssh … 'python3 /root/billing/stripe_setup.py' (Γ—2)

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:

product myapp: found price myapp_pro_monthly: found price myapp_pro_yearly: found feature myapp_pro: found, attached

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.

4

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.

  1. Read the raw body before any JSON parsing.
  2. Take the timestamp t and every v1 signature from the Stripe-Signature header (t=1700000000,v1=abc…).
  3. 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 a v1, and only if t is within five minutes of now, so a captured request can't be replayed.

EXAMPLE β€” YOU ASK CLAUDE CODE

Claude Code
YouAdd a webhook route at /webhooks/stripe that verifies Stripe-Signature over the raw body, re-reads the customer's active entitlements and overwrites the user's entitlements field (find the user by stripe_customer, else metadata pb_user_id). Then a script that recreates the endpoint https://accounts.yourdomain.com/webhooks/stripe for the five events below, saves its signing secret unprinted to /root/secrets/pb.env as STRIPE_WEBHOOK_SECRET, and recreates pb the same way, never printing its environment.
ClaudeDone. The script printed the endpoint: enabled, 5 events. In the container the secret starts with 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");
5

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 clicksWhere the button sends them
Logged outSign-up first
Logged in, no subscriptionCheckout
Has one app's Pro, not bundle_allThe Portal, to switch plans with proration (credit for the unused part of the current plan)
Has bundle_allThe Portal, with a "You're on the bundle" banner

EXAMPLE β€” YOU ASK CLAUDE CODE

Claude Code
YouMake pricing.html one argument, top to bottom: headline, problem, price math, what you get, 3 steps, a placeholder for my note, pricing card, FAQ (frequently asked questions), final button. Every button calls one upgrade(plan) function that refreshes the user first: logged out to sign-up, no entitlements to checkout, any entitlement to the portal. If checkout fails, say "Pro is coming online shortly" and unlock nothing.
ClaudeBuilt with 3 buttons, all calling upgrade(); a search finds no other code calling /api/myapp/checkout. Upload it, open it in a private window (logged out) and click each: all three should open sign-up.↳ Write Β· pricing.html↳ Grep Β· "api/myapp/checkout"

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.

6

Prove the money loop in test mode

Stripe tests its own card page; don't automate that part.

EXAMPLE β€” YOU ASK CLAUDE CODE

Claude Code
YouRun the full loop in test mode and time each step: sign up a test user, give me its checkout link (I'll pay), poll its entitlements until myapp_pro appears, give me its portal link (I'll cancel), and time until they're empty. Prove the user can't PATCH (edit) its own entitlements and logged-out checkout gets 401. Then delete the test user.
ClaudeThe link was a checkout.stripe.com page, session id starting 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:

  1. The live secret key goes on one line in a chmod 600 file on the server.
  2. The script checks it works and backs up the test env file with a timestamp.
  3. It re-runs the setup and webhook scripts in live mode and recreates the container with the live env file.
  4. It sends an unsigned request to the webhook, expects a rejection, and prints the one-line rollback command.
  5. 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_limit and 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.