Module 5 · ~15 min
💳 Add accounts + Stripe billing
The one place laziness stops. You'll leave able to charge for an app without ever leaking a secret key or trusting the browser.
When to add money
Only when the app earns it. Most apps ship free first; you add billing when there's a real reason someone would pay. Billing is the one genuinely un-lazy part of the stack — so only pay that cost once it's justified. A free app that people use beats a paywalled app nobody found.
Two shapes of "get paid"
- One-time — sell a thing once: a playbook, a template, lifetime access. Stripe Checkout in
paymentmode. (This very course runs on this — far less machinery.) - Subscription — recurring access: per-app Pro, an all-access bundle. Checkout in
subscriptionmode plus Stripe's hosted billing portal.
Pick the simplest that fits. One-time has no portal, no dunning, no renewal logic — reach for it whenever the product allows.
The un-lazy boundary — memorize these four
- The secret key stays server-side. The browser only ever receives a hosted Stripe redirect URL. The secret key touches nothing client-side, ever.
- Entitlements are server-verified. Whether someone paid is decided by your server checking Stripe — never a flag the client can set. Never "fake-unlock."
- Webhook signatures are verified. Anyone can POST to your webhook URL. Only trust an event whose Stripe signature validates over the raw body, within a replay window.
- Checkout is hosted. Card details go to Stripe's page, not yours. You build almost no billing UI and never touch a card number.
The one-time gate, end to end (what this course uses)
Three server routes. No database, no user table, no email required. The elegant part: the proof of purchase IS the Stripe session, re-verified server-side each visit — so the buyer's link keeps working forever, on any device, because the server just re-asks Stripe.
// 1) create a one-time Checkout session — the secret key lives HERE, server-side
const s = await stripe('POST', '/v1/checkout/sessions', {
mode: 'payment',
'line_items[0][price]': PRICE_ID,
'line_items[0][quantity]': 1,
success_url: BASE + '/members?session_id={CHECKOUT_SESSION_ID}',
cancel_url: BASE + '/?checkout=cancel',
});
return { url: s.url }; // the browser only ever sees this hosted URL
// 2) the gate — is this session really paid, and for OUR product?
async function paid(sessionId){
const s = await stripe('GET',
'/v1/checkout/sessions/' + sessionId + '?expand[]=line_items');
return s.payment_status === 'paid'
&& s.mode === 'payment'
&& s.line_items.data.some(li => li.price.id === PRICE_ID);
}
// paid() true -> set a signed cookie + serve the content. false -> bounce to checkout.
// 3) webhook — verify the signature over the RAW body before trusting anything
// header "t=<ts>,v1=<sig>"; expected = HMAC_SHA256(secret, t + '.' + rawBody)
// constant-time compare, and reject if |now - t| > 300s (replay window)
⚠️ That "for OUR product?" line-item check is load-bearing if your Stripe account sells more than one thing. A paid session for a different product must never unlock this one. Don't drop it.
The subscription shape (when you outgrow one-time)
For recurring access you add two things: somewhere to remember who a user is, and a way to keep "what they've paid for" in sync with Stripe.
- One identity store. A single small accounts service (a lightweight PocketBase works great) holds users. One login works across all your apps.
- An entitlements mirror. Each user record carries a list of unlocked features (e.g.
["app_pro"]). That list is written only by the Stripe webhook — the client can read it, never write it. - Convergent sync. On any billing event, re-read the customer's active entitlements from Stripe and overwrite your mirror. Idempotent and self-healing: out-of-order or re-delivered events reconverge, so you need no event ledger. Stripe is the source of truth; your DB is a fast cache.
- Hosted portal. Cancel, switch plan, update card — all Stripe's billing portal. You build zero subscription UI.
- All-access is nearly free to model. Attach every app's feature to an umbrella "all-access" product too, and one subscription unlocks the whole fleet.
Never fake-unlock
The mistake that bites beginners: gating in the browser — hiding paid content with CSS or JavaScript. Anyone can view-source. If content is worth money, the server must serve it only after verifying payment, and the protected files must live outside any public static folder. That's exactly how these lessons reach you: they're served by a tiny gated server, not sitting in a public directory.
🦥 Lazy where you can, strict where it counts. Everything else in this course was "smallest thing that works." Money is the exception — here you do it properly, because the failure mode is someone's card or your content leaking.
Your turn ✅
- Decide: does your app charge once, or recurring? Choose the simpler one.
- Put your Stripe secret key in a server-side env file — never in the client.
- Wire hosted Checkout, and verify the purchase server-side on return.
- Add the webhook; verify its signature over the raw body.
- Confirm an unpaid / logged-out request to your gated route is refused.