SermonWise PostHog Funnel — Event Architecture
Knowledge > Products > SermonWise > PostHog Funnel
SermonWise PostHog Funnel — Event Architecture
Shipped 2026-05-07 (PR #339). Live-verified 2026-05-11 (founder click-through).
The 6 Funnel Events
| # | Event | Fires From | Side | distinct_id |
|---|---|---|---|---|
| 1 | signup_form_submitted | SignupForm.tsx | Client | email (pre-auth) |
| 2 | signup_email_confirmed | auth/callback/route.ts | Server | Supabase UID |
| 3 | first_app_visit | sermons/app/page.tsx + /api/sermons/log-first-visit | Client (gated server) | Supabase UID |
| 4 | first_sermon_generated | api/sermons/generate/route.ts | Server | Supabase UID |
| 5 | upgrade_clicked | SermonUpgradeButton.tsx | Client | Supabase UID |
| 6 | upgrade_completed | api/stripe/webhook/route.ts | Server | Supabase UID |
Event Details
1. signup_form_submitted
- Where:
src/components/auth/SignupForm.tsx— fires immediately aftersupabase.auth.signUp()succeeds. - Properties:
app_source: 'sermon_starter',tradition: <string>,newsletter_opted_in: <bool> - distinct_id: User email (pre-auth anonymous session or email-based ID). PostHog merges this with the UUID after
posthog.identify()runs. - Identity:
posthog.identify(userId)is called BEFORE this capture so the event is attributed to the identified Person from the first event.
2. signup_email_confirmed
- Where:
src/app/auth/callback/route.ts— fires server-side after successful PKCE code exchange. - Properties:
app_source: 'sermon_starter',confirmed_via: 'pkce_callback' - distinct_id: Supabase UID (
cbUser.id) - Why server-side: The original client-side
useEffectinsermons/app/page.tsxfailed in production (2026-05-11 verification). Next.js client-side navigation replaced the URL before the effect ran, so?confirmed=1was never seen by the effect. Moving to server-side in the PKCE exchange callback guarantees delivery. - Gating: Only fires when
searchParams.get('flow') === 'signup'(stamped bySignupForm.tsxon the callback URL). This prevents the event from firing on every returning-user PKCE login (OAuth, magic link, re-auth). - Note:
?confirmed=1is still appended to the redirect URL for E2E spec compatibility and URL contract tests.
3. first_app_visit
- Where:
src/app/sermons/app/page.tsx— fires client-side afterPOST /api/sermons/log-first-visitreturnsfired: true. - Server gate:
/api/sermons/log-first-visitdoes an atomicUPDATE profiles SET first_app_visit_at = now() WHERE first_app_visit_at IS NULL RETURNING id. Only the first call per user returnsfired: true. This replaced the broken localStorage gate. - Properties:
app_source: 'sermon_starter' - distinct_id: Supabase UID (after
posthog.identify())
4. first_sermon_generated
- Where:
src/app/api/sermons/generate/route.ts— fires after the first sermon is successfully saved to DB. - Properties:
sermon_id,lens_id(number),lens_name(string) - distinct_id: Supabase UID
5. upgrade_clicked
- Where:
src/components/sermons/SermonUpgradeButton.tsx— fires immediately before the/api/sermons/checkoutfetch. - Properties:
product: 'sermon_pro',billing: 'monthly' | 'annual' - distinct_id: Supabase UID
6. upgrade_completed
- Where:
src/app/api/stripe/webhook/route.ts— fires aftercheckout.session.completedis processed and subscription tier flipped tosermon_pro. - Properties:
product: 'sermon_pro',billing(fromsession.metadata.billing) - distinct_id: Supabase UID
- Deduplication: Uses
posthog_dedup_eventstable to prevent double-firing on retries. - Billing source:
session.metadata?.billing(stamped by checkout route) takes precedence over thestripePriceId.includes('annual')heuristic (which fails for opaque Stripe IDs).
Identity Stitching Pattern
SermonWise uses two distinct_id namespaces:
- Pre-auth: email address (used by
signup_form_submittedclient-side before auth) - Post-auth: Supabase UUID (used by all server-side events and client-side events after login)
Stitching happens at two surfaces:
- SignupForm.tsx — calls
posthog.identify(userId)immediately aftersignUp()returns a user, BEFORE firingsignup_form_submitted. This merges the anonymous browser session with the identified user. - auth/callback → sermons/app —
auth/callbackappends?identify=<uid>to the redirect URL;sermons/app/page.tsxreads this param and callsposthog.identify(uid)in auseEffect. Handles PKCE, magic link, and OAuth sign-in flows where the client session is established server-side.
Server-Side Capture Pattern
capturePostHogServer() is a fire-and-forget helper defined locally in each route file that needs it:
src/app/api/sermons/generate/route.tssrc/app/api/stripe/webhook/route.tssrc/app/auth/callback/route.ts
It uses the PostHog REST capture endpoint (${POSTHOG_HOST}/capture/) with NEXT_PUBLIC_POSTHOG_KEY. The key is write-only (project API key), safe to use server-side. Errors are swallowed — analytics are non-critical.
The helper is duplicated across files intentionally — extracting to a shared lib would require touching the critical-path billing files (BILLING tier in CODEOWNERS). When the next convenient refactor touches those files, consolidate to src/lib/posthog-server.ts.
Contract Tests
| Spec | What it guards |
|---|---|
e2e/contracts/posthog-funnel-events.contract.spec.ts | Every event name exists in its canonical source file; ?confirmed=1 and ?identify= are appended by auth/callback; identify() is called in SignupForm and SigninForm |
e2e/contracts/sermonwise-posthog-funnel.contract.spec.ts | Live PostHog API verification of all 6 events with real Supabase user (requires POSTHOG_PERSONAL_API_KEY + SUPABASE_SERVICE_ROLE_KEY) |
Changelog
| Date | Change | Notes |
|---|---|---|
| 2026-05-11 | Removed legacy sermon_upgrade_click event from SermonUpgradeButton.tsx | Canonical event is upgrade_clicked. Legacy event was duplicating the capture on every button click. No PostHog insight consumers existed; safe to remove. |
Verification History
| Date | Result | Notes |
|---|---|---|
| 2026-05-11 | 5/6 events ✅, signup_email_confirmed ❌ | Found client-side drop; fixed to server-side same day |
| 2026-05-11 (post-fix) | All 6 events expected ✅ | Fix shipped; re-verification needed with fresh test user (john+verify2@churchwiseai.com) |