Stripe — setup notes, reusable across projects
Written while setting up payments for the Courses project (courses.fullstackfusions.com) —
the first project to actually integrate Stripe. Lives here, alongside
clerk_based_auth, as the cross-project reference for the next project that needs
payments, the same way Clerk's setup notes live outside any one project. Sections marked
Courses-specific don't carry over; everything else does.
1. Is a Stripe account reusable across projects? No — same pattern as Clerk
Create a separate Stripe account per distinct business/product, not one shared account for everything. One Stripe login (your email) can own and switch between multiple separate Stripe accounts — each account has its own API keys (test + live), own products/prices, own webhook endpoints, own payout schedule, own dashboard. This is the same reasoning as "one Clerk app per project": mixing unrelated projects under one account mixes their payout reporting, statement descriptors, and blast radius (an issue on one project's account — disputes, a bad actor, a compliance flag — doesn't spill into another project's account if they're separate).
Exception: if a new "project" is really just another product line under the same business entity/brand (e.g. FullStackFusions sells a second digital product under the same brand), one account with multiple Products can make sense. A genuinely different project/brand should get its own account.
What's reusable is the setup knowledge below, not the account or keys themselves. For a new
project: create a fresh Stripe account, make the same category of decisions (§2), get fresh keys
(§4), repeat the local testing setup (§5) — but the actual sk_test_.../pk_test_... values and
Products are per-project and never shared.
2. Setup decisions — the reusable decision framework
Stripe's onboarding asks the same handful of questions for every new account. Decide each deliberately per project:
| Question | Options | How to decide |
|---|---|---|
| How do you want to get started? | "Accept payments" vs "Build a platform or marketplace" | "Accept payments" unless your app itself routes money to multiple separate third-party sellers (Shopify/Uber/Airbnb/Etsy model — that's Stripe Connect, a much bigger integration). One business selling its own thing = Accept payments, always. |
| How do you want to charge customers? | Non-recurring / Recurring / Invoicing / In-person | Non-recurring for pay-once-keep-access products. Recurring only if the product is a subscription with a real renewal/cancellation lifecycle you're ready to model. Invoicing = manual B2B billing, not a self-serve checkout. In-person = physical card readers, irrelevant to a web app. |
| Who should handle global sales tax/VAT? | "Stripe does it" (Managed Payments, +3.5%/transaction) vs "I'll do it" | "Stripe does it" for a solo/small operator — global tax compliance (registering and remitting in every country you get a sale from) is a real legal rabbit hole, disproportionate to handle yourself at small scale. The extra 3.5% buys you out of that liability entirely. Reconsider only once volume/margins make the fee meaningful relative to the compliance burden of self-managing. |
| Setup method | "Set up in the Dashboard" vs "Set up with an AI tool" | "AI tool" adds Stripe integration guidance directly into Claude/Cursor/Codex (a plugin + a docs-backed skill) — worth it whenever an AI coding assistant is building the integration, which is the default assumption for these notes. |
Courses-specific: what was picked
Accept payments, Non-recurring (plan to add Recurring once user base matures), Managed Payments,
set up via the AI-tool path (/plugin install stripe@claude-plugins-official +
npx skills add https://docs.stripe.com).
3. Pricing — Stripe Standard, Canada
2.9% + CA$0.30 per successful transaction, domestic card, no monthly/setup fee — pure
pay-as-you-go. Add-ons: +0.8% international card, +2% currency conversion, +0.5%
manually-keyed card. Managed Payments (§2) adds +3.5% on top if selected. No cheaper standard
tier exists — the only lower rate is volume-gated custom "IC+" pricing, negotiated directly with
Stripe sales once real transaction history exists. Pricing is region-specific — re-check the
/pricing page for the country the account is registered in, not the country of individual
customers, if setting this up somewhere other than Canada.
4. Environment variables — naming convention and where each belongs
| Key | Where it lives | Why |
|---|---|---|
Publishable key (pk_test_... / pk_live_...) |
Frontend, prefixed for your framework's public-env convention (VITE_STRIPE_PUBLISHABLE_KEY for Vite, NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY for Next.js) |
Designed to be public — safe to ship in client-side JS. |
Secret key (sk_test_... / sk_live_...) |
Backend only, plain name, no public-prefix (STRIPE_SECRET_KEY) |
Used server-side to create Checkout Sessions / call Stripe's API. Must never reach the browser bundle — don't prefix it with your framework's public-env marker even by accident, that's the one mistake that actually leaks it. |
Webhook signing secret (whsec_...) |
Backend only (STRIPE_WEBHOOK_SECRET) |
Verifies an incoming webhook request genuinely came from Stripe, not a forged request hitting your public endpoint. Different value for local CLI testing vs. the deployed production webhook — see §5. |
Same "public key in frontend, secret in backend, never swap them" split applies to every project, not just Stripe (same shape as Clerk's publishable/secret key pair).
5. Local testing process (reusable)
- Install the Stripe CLI:
brew install stripe/stripe-cli/stripe(macOS/Homebrew). - Authenticate:
stripe login— opens a browser, links the CLI to your Stripe account. - Forward webhooks to your local backend:
This prints a webhook signing secret valid only for this forwarding session — put it in your backend env asstripe listen --forward-to localhost:8000/webhooks/stripeSTRIPE_WEBHOOK_SECRETwhile testing locally. It's different from the signing secret you'll get from the dashboard once you register the real production webhook endpoint URL — don't reuse the CLI one in production, and don't reuse a production one locally. - Trigger a test event two ways:
- Real flow: go through Checkout with a test card —
4242 4242 4242 4242, any future expiry (e.g.12/34), any 3-digit CVC, any postal code. Use4000 0000 0000 0002to test a declined payment path instead of a successful one. - Synthetic:
stripe trigger checkout.session.completed— fires a fake webhook event without an actual checkout, useful for testing just the webhook handler in isolation.
- Real flow: go through Checkout with a test card —
- Confirm delivery: Stripe Dashboard (test mode) → Developers → Events (or Webhooks) shows
the event and whether your endpoint responded
200.
6. Integration architecture (reusable shape, regardless of project)
- Backend creates a Checkout Session server-side (using the secret key), tagged with
whatever identifiers your app needs to know who/what was purchased (e.g.
user_id,course_idas Stripe metadata) — returns the Checkout URL to the frontend. - Frontend redirects the buyer to that Stripe-hosted Checkout URL. Stripe collects payment, handles 3D Secure/tax/fraud checks itself.
- Stripe sends a webhook (
checkout.session.completed) to your backend once payment succeeds. Your backend verifies the signature (usingSTRIPE_WEBHOOK_SECRET), reads back the metadata from step 1, and grants entitlement in your own database. - Your app's actual access-control logic never talks to Stripe directly at request time — it just checks "does this user have an active entitlement record," same as any other grant path (admin-granted, promo code, etc.). Stripe is just one more writer of that record.
Courses-specific: this record is the enrollments table (source='stripe',
external_ref=<session/payment id>) — see COURSES_MVP_PLAN.md §8 for the exact schema tie-in.
For a new project, the equivalent is whatever table already represents "this user has access to
this thing."
7. What to redo per new project (not reusable)
- A fresh Stripe account and its keys (§1).
- The decision matrix in §2, made fresh — don't assume the same answers apply.
- Products/Prices — specific to what's actually being sold.
- The webhook endpoint URL registered in the Stripe dashboard (differs per project's domain).
- The local
STRIPE_WEBHOOK_SECRETfrom a freshstripe listenrun (§5) — regenerated every time you start the CLI forwarding, not a stored value.
Related
- clerk_based_auth — same "one account/app per project, shared setup knowledge" reasoning (§1), same public/secret key split (§4)
- clerk_production_setup_walkthrough — the Clerk equivalent of this doc: plan vs. actual-dashboard-walkthrough split, worth mirroring here once a second project's Stripe setup surfaces real gotchas
- TechResearch/_index