💳 Stripe Payment Integration¶
Last updated: 8 May 2026 Status: Live ✅ — Stripe live mode, $9 individual cert pricing (single-tier) Architecture: Stripe Checkout + Cloudflare KV + Licence Keys Recent change: Vendor pass + all-access retired on 8 May 2026 (zero historical sales). See Pricing Decision May 2026.
How It All Fits Together¶
┌──────────────────────────────────────────────────────────────────┐
│ USER'S BROWSER │
│ │
│ Cert Page Practice Page (PracticeQuiz.tsx) │
│ ┌────────────┐ ┌─────────────────────────┐ │
│ │ Unlock $9 │──┐ │ hasAccess() checks │ │
│ └────────────┘ │ │ localStorage for token │ │
│ │ │ │ │
│ │ │ Free: 20 questions │ │
│ │ │ Paid: all questions │ │
│ │ │ │ │
│ │ │ "Have a key?" → activate │ │
│ │ └────────────┬─────────────┘ │
└──────────────────┼─────────────────────────┼────────────────────┘
│ │
▼ ▼
┌──────────────────────────────────────────────────────────────────┐
│ CLOUDFLARE PAGES FUNCTIONS │
│ │
│ /api/checkout ──→ Creates Stripe session, returns URL │
│ /api/webhook ──→ Stripe calls this after payment │
│ /api/verify ──→ Client calls after redirect, returns key │
│ /api/activate ──→ Licence key validation for new devices │
└──────────────────────────┬───────────────────────────────────────┘
│
┌────────────┼────────────┐
▼ ▼ ▼
┌────────┐ ┌─────────┐ ┌──────────┐
│ Stripe │ │ Stripe │ │Cloudflare│
│Checkout│ │Webhooks │ │ KV │
│(hosted)│ │ │ │ │
└────────┘ └─────────┘ └──────────┘
The Purchase Flow — Step by Step¶
Scenario: Sarah buys AZ-900 practice exam ($9)¶
Step 1 — Sarah clicks "Unlock $9" on the AZ-900 cert page
The button calls our checkout API:
The server creates a Stripe Checkout Session with:
- Price ID for the $9 cert product
- Metadata:
{ productType: "cert", certCode: "az-900", vendorSlug: "microsoft" } - Success URL:
/guided/az-900/practice/?checkout_session={CHECKOUT_SESSION_ID} - Cancel URL:
/guided/az-900/ customer_creation: "always"— ensures Stripe captures email
Returns: { url: "https://checkout.stripe.com/c/pay/cs_test_xxx" }
The browser redirects Sarah to Stripe's hosted checkout page.
Step 2 — Sarah pays on Stripe
She enters: - Email: sarah@example.com - Card: 4242 4242 4242 4242 (test) or real card (live) - Stripe processes the payment
Step 3 — Two things happen in parallel:
3a. Stripe webhook fires → /api/webhook
The webhook handler:
- Verifies the Stripe signature (prevents spoofing)
- Checks
payment_status === 'paid'(skips async payment methods) - Checks if a key already exists for this session (idempotent)
- Generates a licence key:
GD-7F3K-BX9M-QW2P - Writes 3 records to KV:
licence:GD-7F3K-BX9M-QW2P→ full purchase recordsession:cs_test_xxx→"GD-7F3K-BX9M-QW2P"(lookup table)email:{sha256("sarah@example.com")}→["GD-7F3K-BX9M-QW2P"]
3b. Stripe redirects Sarah back to our site
PracticeQuiz.tsx detects the URL parameter on mount and calls:
The verify endpoint:
- First checks KV:
session:cs_test_xxx— if the webhook already created a key, returns it immediately - If KV is empty (webhook hasn't fired yet): calls Stripe API to verify the session
- If paid: generates a NEW key, writes to KV, returns it
- When the webhook eventually fires, it sees the session already has a key → skips (idempotent)
Returns:
{
"licenceKey": "GD-7F3K-BX9M-QW2P",
"productType": "cert",
"certCode": "az-900",
"vendorSlug": "microsoft",
"expiresAt": "2027-04-24T09:00:00.000Z"
}
Step 4 — Client stores access and shows the key
handleCheckoutReturn() in checkout.ts:
- Calls
/api/verifywith the session ID - Stores the access info in localStorage:
guided-access-cert-az-900 - Cleans the
checkout_sessionparam from the URL (security — prevents leaking via screenshots/sharing) - Sets
justPurchasedstate → shows the licence key banner with copy button
Sarah sees:
✅ Purchase complete!
Your licence key:
┌──────────────────────────┐
│ GD-7F3K-BX9M-QW2P 📋 │ ← copy button
└──────────────────────────┘
Save this key! You'll need it on other devices.
It's also in your Stripe receipt email.
All questions are now unlocked. The question count selector shows [10, 20, 50, All] instead of [10, 20, 🔒50, 🔒All].
The New Device Flow — Step by Step¶
Scenario: Sarah opens her phone browser¶
- Sarah visits
/guided/az-900/practice/on her phone - Phone browser has no localStorage data —
hasAccess("az-900", "microsoft")returns false - She sees: "🔒 20 free questions · Unlock all with a licence key"
- She clicks "Have a key?" → activation input appears
- She enters
GD-7F3K-BX9M-QW2Pand clicks Activate
The server:
- Normalises the key (uppercase, validates format)
- Looks up
licence:GD-7F3K-BX9M-QW2Pin KV - Checks: not expired? Under activation limit (3)?
- Increments
activationsfrom 0 → 1 (or 1 → 2, etc.) - Returns the access info
The client stores it in localStorage. Quiz unlocked. ✅
Licence Key System — Full Details¶
Key Format¶
GD-XXXX-XXXX-XXXX
GD = "Guided" prefix (constant)
X = Random character from: ABCDEFGHJKLMNPQRSTUVWXYZ23456789
(28 characters — no I, O, 0, or 1 to avoid visual confusion)
Entropy: 28^12 ≈ 1.3 × 10^17 possible keys. At 1 billion guesses per second, it would take 4 years to brute-force. Nobody is doing this for a $9 product.
Key Normalisation¶
Users might type the key with or without dashes, lowercase, extra spaces. The normaliseLicenceKey() function handles all of this:
"gd-7f3k-bx9m-qw2p" → "GD-7F3K-BX9M-QW2P" ✅
"GD7F3KBX9MQW2P" → "GD-7F3K-BX9M-QW2P" ✅
"gd 7f3k bx9m qw2p" → "GD-7F3K-BX9M-QW2P" ✅
"ABCDEF" → null (too short) ❌
"XX-7F3K-BX9M-QW2P" → null (wrong prefix) ❌
Activation Limit¶
Each key allows 3 device activations. Tracked in the KV record:
If a user hits the limit:
"This key has reached its 3-device limit. Contact aguidetocloud@gmail.com for help."
Admin action: You can increase maxActivations manually in Cloudflare KV Dashboard if a legitimate user needs more devices.
KV Record Structure¶
licence:{key} — The main purchase record:
{
"email": "sarah@example.com",
"productType": "cert",
"certCode": "az-900",
"vendorSlug": "microsoft",
"expiresAt": "2027-04-24T09:00:00.000Z",
"createdAt": "2026-04-24T09:00:00.000Z",
"activations": 1,
"maxActivations": 3,
"stripeSessionId": "cs_test_xxx",
"amountTotal": 1900,
"currency": "usd"
}
session:{stripeSessionId} — Lookup table so /api/verify can find the key:
email:{sha256(email)} — List of all keys for an email (for potential future restore):
All KV entries have a 400-day TTL (35-day buffer beyond the 365-day access period). After TTL, Cloudflare auto-deletes them.
Why Email is Hashed in KV Keys¶
Privacy. The raw email is stored inside the KV value (needed for admin lookup), but KV keys appear in logs and admin tooling. sha256("sarah@example.com") prevents casual PII exposure.
Access Gating in PracticeQuiz.tsx¶
What Changed (Minimal — ~30 lines added)¶
- Import —
access.tsandcheckout.tsimported at the top - Props —
certCodeandvendorSlugadded toPracticeQuizProps - State — 6 new state variables:
isPremium,showActivation,activationKey,activationError,activationLoading,justPurchased - Mount effect — Checks
hasAccess()on mount + callshandleCheckoutReturn()for post-purchase redirect - Question count gate —
QUESTION_COUNTSbuttons for 50 and All show a 🔒 and are disabled for free users - UI banners — Three conditional sections before the mode cards:
- Purchase success banner (shows licence key + copy button)
- Free user upgrade banner (shows activation input)
- Premium badge (simple "✅ All questions unlocked")
Access Hierarchy¶
hasAccess(certCode, vendorSlug):
1. localStorage['guided-access-cert-{certCode}'] → cert-specific purchase
2. localStorage['guided-access-vendor-{vendorSlug}'] → vendor pass
3. localStorage['guided-access-all'] → all-access pass
First match wins. A vendor pass unlocks ALL certs for that vendor. All-access unlocks everything.
Free Question Selection¶
When a free user starts a quiz, they can only pick from 20 questions (10 and 20 are the only options). The selectFreeQuestions() function in access.ts ensures:
- Deterministic — same 20 questions every time (sorted by ID, not random)
- Proportional per domain — if a cert has 5 domains, each domain contributes proportionally
- Why deterministic? Random would let users refresh to see different questions, undermining the paywall
Stripe Configuration¶
🔴 Pricing simplified — 8 May 2026. Vendor pass + all-access tiers retired. Going forward Guided sells one product only: $9 single-cert pass with 1 year access. See Pricing Decision May 2026 for the full rationale and rollback path.
Products (Test Mode)¶
| Product | Price | Product ID | Price ID | Status |
|---|---|---|---|---|
| Single Cert | $9 | prod_UOTBGfdc1ZasD1 |
price_1TPgF9IXZo4phfVPAtBWXTYT |
✅ Active |
| ~~Vendor Pass~~ | ~~$59~~ | prod_UOTB9lyKQwFFiW |
price_1TPgFAIXZo4phfVPRyITlq5j |
🗄️ Retired (8 May 2026) |
| ~~All Access~~ | ~~$149~~ | prod_UOTBAbcOSsMcgu |
price_1TPgFBIXZo4phfVPn53CmYkA |
🗄️ Retired (8 May 2026) |
Products (Live Mode)¶
| Product | Price | Product ID | Price ID | Status |
|---|---|---|---|---|
| Single Cert | $9 | prod_UOgfX7gOBynDZq |
price_1TRNTjIXZo4phfVPCLjvHSGH |
✅ Active |
| Single Cert (old) | ~~$19~~ | prod_UOgfX7gOBynDZq |
price_1TPtI9IXZo4phfVPNOTOkP1V |
🗄️ Archived 29 Apr 2026 |
| ~~Vendor Pass~~ | ~~$59~~ | prod_UOgf0LkyhFTAZR |
price_1TPtIAIXZo4phfVPI5rF0x4T |
🗄️ Archived 8 May 2026 (zero historical sales) |
| ~~All Access~~ | ~~$149~~ | prod_UOgf19snROsc4Y |
price_1TPtIBIXZo4phfVPvcv01IhT |
🗄️ Archived 8 May 2026 (zero historical sales) |
Code reference: functions/lib/utils.ts — STRIPE_PRICES object (now contains cert only).
These are test mode products. They accept test card numbers (4242 4242 4242 4242) and don't charge real money.
Webhook¶
| Setting | Value |
|---|---|
| URL | https://aguidetocloud.com/api/webhook |
| Events | checkout.session.completed |
| Webhook ID | we_1TPgLoIXZo4phfVPPUpVQKLM |
| Signing secret | Stored in Cloudflare Pages env vars and ~/.copilot/secrets/stripe-webhook-secret |
API Keys Location¶
| Key | Where stored |
|---|---|
Secret key (sk_test_...) |
Cloudflare Pages env var STRIPE_SECRET_KEY + ~/.copilot/secrets/stripe-test-secret-key |
Publishable key (pk_test_...) |
Cloudflare Pages env var STRIPE_PUBLISHABLE_KEY |
Webhook secret (whsec_...) |
Cloudflare Pages env var STRIPE_WEBHOOK_SECRET + ~/.copilot/secrets/stripe-webhook-secret |
⚠️ Never commit keys to git. They live only in Cloudflare env vars and the local secrets folder.
Cloudflare Configuration¶
KV Namespaces¶
| Environment | Namespace ID | Binding Name |
|---|---|---|
| Production | 49f5ddbc16c542ecb3ee92d767732bef |
GUIDED_KV |
| Preview | 55ce078a5e7240adabe74ea82ba52fe8 |
GUIDED_KV |
Environment Variables¶
Set in Cloudflare Dashboard → Pages → guided → Settings → Environment Variables:
| Variable | Type | Both envs? |
|---|---|---|
STRIPE_SECRET_KEY |
Encrypted | ✅ |
STRIPE_WEBHOOK_SECRET |
Encrypted | ✅ |
STRIPE_PUBLISHABLE_KEY |
Plaintext | ✅ |
Admin Operations¶
Looking Up a Customer's Purchase¶
If a customer emails saying "I paid but can't access":
- Go to Stripe Dashboard (toggle test/live mode)
- Search by their email
- Find the payment → expand → see the metadata (certCode, vendorSlug, productType)
- Look at the checkout session ID
To find their licence key:
- Go to Cloudflare Dashboard → Workers & Pages → KV
- Open
GUIDED_KVnamespace - Search for key:
session:{checkoutSessionId}→ this gives you the licence key - Send them the key
Revoking a Shared/Abused Key¶
- Find the key in KV:
licence:{key} - Delete the KV entry
- The key is now invalid —
/api/activatewill return "key not found" - If the legitimate buyer needs a new key, create one manually or ask them to re-purchase (then refund the old one)
Increasing Device Limit¶
- Find the key in KV:
licence:{key} - Edit the JSON value → change
maxActivationsfrom 3 to whatever - Save
Checking Revenue / Sales¶
- Stripe Dashboard → Payments — all transactions
- Stripe Dashboard → Products — per-product breakdown
- KV
email:*entries give you a rough count of unique customers
Security & Known Limitations¶
V1: Client-Side Gate (Soft Paywall)¶
All question data is in the static JS bundle shipped to every user. The gate only controls the UI — a developer could find all 200 questions in DevTools.
Why this is acceptable for V1:
- Target audience is cert students studying for exams, not developers inspecting bundles
- The value is the interactive experience (explanations, exam simulation, flashcards with spaced repetition, progress tracking, adaptive learning) — not raw question text
- At $9, the friction of finding/parsing JSON from a minified bundle exceeds just paying
- Even Udemy courses can be screen-recorded — no DRM is perfect
- V2 can add server-side question delivery (serve questions from a Pages Function that checks auth first). This is a non-destructive upgrade
Licence Key Sharing¶
- 3-device activation limit mitigates casual sharing
- Systematic sharing (posting key on Reddit) can be detected by monitoring activation counts
- Remediation: revoke key → issue new one to original buyer
- At $9, sharing is a pricing problem, not a security problem
Webhook Reliability¶
- Stripe retries failed webhooks for up to 3 days
/api/verifyhandles the race condition (webhook not fired yet) by checking Stripe directly- Webhook writes are idempotent — duplicate events don't create duplicate keys
Session ID in URL¶
- The
checkout_session=cs_xxxparameter is cleaned from the URL immediately after verification - This prevents leaking via screenshots, browser history, or shared links
- Even if someone captured a session ID,
/api/verifyonly returns the key (not card details)
Switching to Live Mode¶
When ready to accept real payments:
- Create live Stripe products — In Stripe Dashboard, toggle OFF test mode, create the same 3 products/prices
- Update price IDs — Edit
functions/lib/utils.ts→STRIPE_PRICESwith the newprice_live_xxxIDs - Update API keys — In Cloudflare Pages env vars:
STRIPE_SECRET_KEY→sk_live_xxxSTRIPE_PUBLISHABLE_KEY→pk_live_xxx
- Create live webhook — In Stripe Dashboard → Webhooks → Add endpoint for live mode → Update
STRIPE_WEBHOOK_SECRET - Test with real card — Buy the $9 product with your own card → verify the full flow → refund yourself
- Launch 🚀
Don't forget
Live mode uses different API keys, different product/price IDs, and a different webhook signing secret. All three must be updated together.
Refund Policy¶
- 7-day full refund — no questions asked
- Process refunds through Stripe Dashboard → Payments → find the charge → Refund
- After refund, the licence key still works until it expires (no automatic revocation)
- If refund abuse is detected, manually delete the licence key from KV
Future Improvements (V2+)¶
| Improvement | Priority | Effort | Description |
|---|---|---|---|
| Server-side question delivery | Medium | ~3 hours | Don't ship premium questions in the static bundle. Serve via /api/questions/{cert} with auth check |
| Google Sign-In | Low | ~2 hours | One-click login as alternative to licence key entry |
| Email-based restore (OTP) | Low | ~2 hours | "Forgot key?" → enter email → receive code → verify → get key. Needs email service (Resend free tier) |
| Student discount | Low | ~1 hour | 20% off with .edu email verification |
| Team/bulk pricing | Low | ~3 hours | Custom Stripe pricing for training departments |
| Auto-renewal reminders | Medium | ~1 hour | Email 30 days before expiry with renewal link |
| Vendor page pricing buttons | Medium | ~30 min | Wire $59 vendor pass buttons on [vendor]/index.astro pages |
Lessons Learned (24 April 2026 build session)¶
Gotchas to remember
These caused real bugs during the build. Don't repeat them.
1. Worker proxy breaks request.url origin¶
Problem: Pages Functions use new URL(request.url).origin to build redirect URLs. When the function runs behind a Worker proxy (e.g. www.aguidetocloud.com → guided-49q.pages.dev), the origin resolves to the Pages project domain, not the public domain.
Impact: Stripe redirected users to guided-49q.pages.dev/guided/az-900/practice/ instead of www.aguidetocloud.com/guided/az-900/practice/. The checkout return handler never fired because the user was on the wrong domain.
Fix: Hardcode the public domain in the checkout function:
2. Non-www vs www Worker routes¶
Problem: The site redirects aguidetocloud.com → www.aguidetocloud.com. Worker routes were only set on the non-www domain. After the redirect, the Worker route didn't match.
Impact: /guided/* and /api/* routes returned 404 on www.aguidetocloud.com.
Fix: Create Worker routes for BOTH domains:
aguidetocloud.com/guided/* → guided-router
www.aguidetocloud.com/guided/* → guided-router ← was missing
aguidetocloud.com/api/* → guided-router
www.aguidetocloud.com/api/* → guided-router ← was missing
3. Client-side password gate was NOT Cloudflare Access¶
Problem: When looking for the password gate, we assumed it was Cloudflare Access (Pages project setting). It was actually a custom client-side SHA-256 gate in BaseLayout.astro — 164 lines of HTML/JS that hid all content behind display:none until the correct code was entered.
Lesson: Check the actual layout template code before investigating Cloudflare settings. The gate was visible in BaseLayout.astro line 108.
4. window is not available during SSR¶
Problem: Used window.location.pathname in JSX of a React component (PracticeQuiz.tsx). Astro builds pages at build time (SSR), where window doesn't exist.
Impact: ReferenceError: window is not defined — build fails.
Fix: Never use window directly in JSX return. Use it only inside useEffect (runs client-side only) or guard with typeof window !== 'undefined'.
5. Stripe Checkout custom_fields for marketing consent¶
Problem: Needed to collect marketing consent legally (GDPR/CAN-SPAM). Can't just email buyers without opt-in.
Solution: Stripe Checkout supports custom_fields — added an optional dropdown:
The response is in session.custom_fields[].dropdown.value. Stored as marketingConsent: boolean in the KV licence record.
6. Webhook vs verify race condition is real¶
Problem: Stripe webhook may fire AFTER the user lands on the success URL. If /api/verify only checks KV (which the webhook writes to), it may find nothing.
Solution: /api/verify checks KV first. If empty, calls Stripe API directly to verify the session. If paid, generates the licence key itself and writes to KV. The webhook is idempotent — checks session:{id} before generating a duplicate key.
7. Payment architecture decision tree¶
For future reference, the options we evaluated:
| Option | Chosen? | Why |
|---|---|---|
| Stripe + localStorage tokens | ❌ | Device-locked, no multi-device |
| Stripe + KV + magic link email | ❌ | Spam folder risk, needs email service |
| Stripe + KV + Google Sign-In | ❌ | Over-engineering for V1 |
| Stripe + KV + Stripe Customer Portal | ❌ | Slightly better but still complex |
| Stripe + KV + licence keys | ✅ | Simple, users understand it, no dependencies |
| LemonSqueezy (replace Stripe) | ❌ | 5-9% fee, less control |
Key insight: "The best payment system is the one that exists and takes money." Ship simple, add complexity based on real user feedback, not hypothetical problems.
8. Hardcoded noindex survives gate removal (25 April 2026)¶
Problem: When the pre-launch password gate was removed from BaseLayout.astro, a hardcoded <meta name="robots" content="noindex, nofollow"> on line 44 was left behind. It was separate from the conditional noIndex prop — easy to miss.
Impact: Google rejected all indexing requests ("Indexing request rejected — issues detected"). Every page on the site was telling Google not to index it.
Fix: Remove the hardcoded meta tag. The conditional {noIndex && <meta ...>} remains for pages that genuinely need noindexing.
Lesson: When removing pre-launch protections, always audit for ALL related artifacts:
- [x] Password gate HTML/JS
- [x]
noindex, nofollowmeta tags (check for BOTH conditional and hardcoded) - [x]
robots.txtDisallow rules - [x] Canonical URL domain (non-www vs www mismatch)
9. Canonical URL domain mismatch (25 April 2026)¶
Problem: Astro config had site: 'https://aguidetocloud.com' (non-www) but the live site serves on www.aguidetocloud.com. All canonical URLs and sitemap URLs pointed to the wrong domain.
Impact: Google may treat www and non-www as different sites. Canonical signals were pointing to a domain that 301-redirects.
Fix: Changed astro.config.mjs to site: 'https://www.aguidetocloud.com'. Rebuild regenerates all canonical URLs and sitemap.
Launch Checklist¶
Completed ✅¶
- [x] Payment flow tested end-to-end (purchase → key displayed → activation on new device)
- [x] All 4 API endpoints working (checkout, webhook, verify, activate)
- [x] 3 Stripe test products active ($9, $59, $149)
- [x] Licence key generation + KV storage working
- [x] 3-device activation limit working
- [x] Marketing consent checkbox on Stripe Checkout
- [x] Password gate removed
- [x]
noindexremoved — site is indexable - [x] Canonical URLs fixed (www.aguidetocloud.com)
- [x] Payment FAQs on all pages (homepage, vendor, cert) + JSON-LD
- [x] Help page live at
/guided/help/ - [x] Nav redesigned (Vendors dropdown, Help link)
- [x] Orange activation UI on practice page + cert landing page
- [x] Orange licence key banner post-purchase
- [x] GSC: 7 priority URLs submitted + sitemap (1,212 URLs)
- [x] Comprehensive documentation in learning portal
Remaining for go-live 🔲¶
- [ ] Switch Stripe to live mode — need
sk_live_andpk_live_from Sutheesh- Create 3 live products via API
- Create live webhook endpoint
- Update price IDs in
functions/lib/utils.ts - Update Cloudflare Pages env vars with live keys
- Test with real card ($9) → verify → refund
- [ ] Add Guided to main site — link in aguidetocloud.com homepage/nav
- [ ] Verify GSC sitemap — check status changes to "Success"
- [ ] Verify rich results — FAQPage schema appearing after Google indexes
Post-launch 🔲¶
- [ ] Monitor first 10 purchases in Stripe Dashboard
- [ ] Check for support emails (lost keys, refund requests)
- [ ] Early-bird discount consideration ($14.99 for first 50 customers)
- [ ] Blog post announcing Guided launch
- [ ] Wire vendor page pricing buttons ($59 vendor pass)
Files Reference¶
functions/
api/
checkout.ts — Creates Stripe Checkout Session
webhook.ts — Handles checkout.session.completed, generates key
verify.ts — Post-redirect verification, returns key
activate.ts — Licence key activation for new devices
lib/
utils.ts — Key generation, normalisation, price IDs, vendor map
env.d.ts — TypeScript type definitions for Env
src/
lib/
access.ts — Client-side access checking (localStorage read/write)
checkout.ts — Checkout initiation, post-purchase handling, key activation
pages/
help/index.astro — User-facing help page with FAQ + JSON-LD
[slug]/index.astro — Pricing buttons + activation form + checkout return handler
[cert]/practice.astro — Passes certCode + vendorSlug to PracticeQuiz
components/
interactive/
PracticeQuiz.tsx — Access gate, Buy button ($9), licence key activation
wrangler.toml — KV namespace bindings
📋 Price Change History¶
| Date | Change | Rationale | Commit |
|---|---|---|---|
| 29 Apr 2026 | Individual cert: $19 → $9 | Accessibility for new brand. $9 is impulse-buy territory. Undercuts Whizlabs ($14-20), MeasureUp ($99+). Revisit at 500+ sales or Jan 2027. | Guided 0c43faf |
| 25 Apr 2026 | Initial pricing: $19 / $59 / $149 | Launch pricing. Cert $19, vendor pass $59, all-access $149. | — |
How to Change Prices in Future¶
- Create new Stripe price via API or Dashboard (can't edit existing price amounts)
- Archive old price (
active=false) — preserves purchase history - Update Price ID in
functions/lib/utils.ts→STRIPE_PRICES.cert - Update display price in these files (search for
?? 9or$9): src/components/interactive/PracticeQuiz.tsx— Buy buttonsrc/pages/[slug]/index.astro— 6 locations (hero, nav sidebar, FAQ)src/pages/[vendor]/index.astro— 3 FAQ answerssrc/pages/index.astro— homepage FAQ + comparison tablesrc/pages/explore/index.astro— fallback pricesrc/pages/help/index.astro— pricing cardsrc/layouts/PathLayout.astro— pricing card- Update this doc — add row to Price Change History table
- Stripe API key at
~/.copilot/secrets/stripe-live-secret-key