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 8 Funnel Events
Events 0a and 0b were added 2026-08-14. Until then the funnel began at
signup_form_submitted, which meant organic acquisition and product activation
were two unconnected halves: there was no way to answer "does the Psalm 23 page
actually produce sermons?", or to compare one landing-page cluster against
another, because nothing was captured before the signup form. landing_cluster
is carried as a property rather than reverse-engineered from $current_url
later.
| # | Event | Fires From | Side | distinct_id |
|---|---|---|---|---|
| 0a | seo_cta_clicked | SermonGeneratorCta.tsx | Client | anonymous (pre-auth) |
| 0b | generator_started | OutlineGeneratorStarter.tsx | Client | anonymous (pre-auth) |
| 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
0a. seo_cta_clicked
- Where:
src/components/sermons/SermonGeneratorCta.tsx— fires in the click handler (not an effect, so a re-render cannot replay it). - Properties:
landing_cluster(passage-hub|passage-tradition|passage-index|outline-generator|funeral-prep|template-occasion|prep-desk|homepage),cta_placement(hero,angle-3,tradition-reformed,footer,final-cta…),passage,tradition,has_theme. - distinct_id: anonymous browser ID. PostHog merges it into the Person when
posthog.identify()runs at signup, so a pre-signup CTA click and the resulting account are the same Person. - Privacy: page-derived facts only. A scripture reference is published page metadata, not user-generated content. No sermon text, no pastoral notes, no names, no email.
0b. generator_started
- Where:
src/components/sermons/OutlineGeneratorStarter.tsx— the input block on/sermon-outline-generator, fired on submit. - Properties:
landing_cluster: 'outline-generator',cta_placement: 'hero-form',has_passage(boolean),tradition. - Note:
has_passageis deliberately a boolean, not the value. That field is free text a human typed, so it is counted and never shipped — unlikepassageon 0a, which is a fixed page constant. - Not the same as
sermon_generation_started(SermonWizard.tsx), which fires inside the authenticated wizard when generation actually begins. 0b is intent at the front door; that one is the engine turning over.
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-08-14 | Added seo_cta_clicked + generator_started | The funnel began at signup_form_submitted, so no landing page could be tied to an activation. Shipped alongside the passage hubs and /sermon-outline-generator. |
| 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) |
| 2026-08-14 | seo_cta_clicked + generator_started shipped, NOT yet observed in PostHog | Wired and browser-verified to fire on click/submit against a local production build. No live PostHog read has confirmed ingestion. Confirm both appear before trusting any landing-cluster report. |