Skip to content

💳 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:

POST /api/checkout
Body: { productType: "cert", certCode: "az-900", vendorSlug: "microsoft" }

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

POST /api/webhook
Event: checkout.session.completed

The webhook handler:

  1. Verifies the Stripe signature (prevents spoofing)
  2. Checks payment_status === 'paid' (skips async payment methods)
  3. Checks if a key already exists for this session (idempotent)
  4. Generates a licence key: GD-7F3K-BX9M-QW2P
  5. Writes 3 records to KV:
    • licence:GD-7F3K-BX9M-QW2P → full purchase record
    • session: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

/guided/az-900/practice/?checkout_session=cs_test_xxx

PracticeQuiz.tsx detects the URL parameter on mount and calls:

GET /api/verify?session_id=cs_test_xxx

The verify endpoint:

  1. First checks KV: session:cs_test_xxx — if the webhook already created a key, returns it immediately
  2. If KV is empty (webhook hasn't fired yet): calls Stripe API to verify the session
  3. If paid: generates a NEW key, writes to KV, returns it
  4. 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:

  1. Calls /api/verify with the session ID
  2. Stores the access info in localStorage: guided-access-cert-az-900
  3. Cleans the checkout_session param from the URL (security — prevents leaking via screenshots/sharing)
  4. Sets justPurchased state → 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

  1. Sarah visits /guided/az-900/practice/ on her phone
  2. Phone browser has no localStorage data — hasAccess("az-900", "microsoft") returns false
  3. She sees: "🔒 20 free questions · Unlock all with a licence key"
  4. She clicks "Have a key?" → activation input appears
  5. She enters GD-7F3K-BX9M-QW2P and clicks Activate
POST /api/activate
Body: { key: "GD-7F3K-BX9M-QW2P" }

The server:

  1. Normalises the key (uppercase, validates format)
  2. Looks up licence:GD-7F3K-BX9M-QW2P in KV
  3. Checks: not expired? Under activation limit (3)?
  4. Increments activations from 0 → 1 (or 1 → 2, etc.)
  5. 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:

{
  "activations": 1,
  "maxActivations": 3
}

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:

"GD-7F3K-BX9M-QW2P"

email:{sha256(email)} — List of all keys for an email (for potential future restore):

["GD-7F3K-BX9M-QW2P", "GD-9X2M-KP4T-WQ7N"]

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)

  1. Importaccess.ts and checkout.ts imported at the top
  2. PropscertCode and vendorSlug added to PracticeQuizProps
  3. State — 6 new state variables: isPremium, showActivation, activationKey, activationError, activationLoading, justPurchased
  4. Mount effect — Checks hasAccess() on mount + calls handleCheckoutReturn() for post-purchase redirect
  5. Question count gateQUESTION_COUNTS buttons for 50 and All show a 🔒 and are disabled for free users
  6. 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.tsSTRIPE_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":

  1. Go to Stripe Dashboard (toggle test/live mode)
  2. Search by their email
  3. Find the payment → expand → see the metadata (certCode, vendorSlug, productType)
  4. Look at the checkout session ID

To find their licence key:

  1. Go to Cloudflare Dashboard → Workers & Pages → KV
  2. Open GUIDED_KV namespace
  3. Search for key: session:{checkoutSessionId} → this gives you the licence key
  4. Send them the key

Revoking a Shared/Abused Key

  1. Find the key in KV: licence:{key}
  2. Delete the KV entry
  3. The key is now invalid — /api/activate will return "key not found"
  4. 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

  1. Find the key in KV: licence:{key}
  2. Edit the JSON value → change maxActivations from 3 to whatever
  3. Save

Checking Revenue / Sales


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/verify handles 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_xxx parameter 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/verify only returns the key (not card details)

Switching to Live Mode

When ready to accept real payments:

  1. Create live Stripe products — In Stripe Dashboard, toggle OFF test mode, create the same 3 products/prices
  2. Update price IDs — Edit functions/lib/utils.tsSTRIPE_PRICES with the new price_live_xxx IDs
  3. Update API keys — In Cloudflare Pages env vars:
    • STRIPE_SECRET_KEYsk_live_xxx
    • STRIPE_PUBLISHABLE_KEYpk_live_xxx
  4. Create live webhook — In Stripe Dashboard → Webhooks → Add endpoint for live mode → Update STRIPE_WEBHOOK_SECRET
  5. Test with real card — Buy the $9 product with your own card → verify the full flow → refund yourself
  6. 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.comguided-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:

const origin = 'https://www.aguidetocloud.com';  // NOT new URL(request.url).origin

2. Non-www vs www Worker routes

Problem: The site redirects aguidetocloud.comwww.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'.

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:

"Send me cert tips & new content updates" → Yes / No

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, nofollow meta tags (check for BOTH conditional and hardcoded)
  • [x] robots.txt Disallow 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] noindex removed — 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_ and pk_live_ from Sutheesh
    1. Create 3 live products via API
    2. Create live webhook endpoint
    3. Update price IDs in functions/lib/utils.ts
    4. Update Cloudflare Pages env vars with live keys
    5. 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

  1. Create new Stripe price via API or Dashboard (can't edit existing price amounts)
  2. Archive old price (active=false) — preserves purchase history
  3. Update Price ID in functions/lib/utils.tsSTRIPE_PRICES.cert
  4. Update display price in these files (search for ?? 9 or $9):
  5. src/components/interactive/PracticeQuiz.tsx — Buy button
  6. src/pages/[slug]/index.astro — 6 locations (hero, nav sidebar, FAQ)
  7. src/pages/[vendor]/index.astro — 3 FAQ answers
  8. src/pages/index.astro — homepage FAQ + comparison table
  9. src/pages/explore/index.astro — fallback price
  10. src/pages/help/index.astro — pricing card
  11. src/layouts/PathLayout.astro — pricing card
  12. Update this doc — add row to Price Change History table
  13. Stripe API key at ~/.copilot/secrets/stripe-live-secret-key
# Example: create new price via API
curl -u "sk_live_...: " https://api.stripe.com/v1/prices \
  -d unit_amount=900 \
  -d currency=usd \
  -d product=prod_UOgfX7gOBynDZq \
  -d "metadata[note]=Price change description"