Skip to main content

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.

#EventFires FromSidedistinct_id
0aseo_cta_clickedSermonGeneratorCta.tsxClientanonymous (pre-auth)
0bgenerator_startedOutlineGeneratorStarter.tsxClientanonymous (pre-auth)
1signup_form_submittedSignupForm.tsxClientemail (pre-auth)
2signup_email_confirmedauth/callback/route.tsServerSupabase UID
3first_app_visitsermons/app/page.tsx + /api/sermons/log-first-visitClient (gated server)Supabase UID
4first_sermon_generatedapi/sermons/generate/route.tsServerSupabase UID
5upgrade_clickedSermonUpgradeButton.tsxClientSupabase UID
6upgrade_completedapi/stripe/webhook/route.tsServerSupabase 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_passage is deliberately a boolean, not the value. That field is free text a human typed, so it is counted and never shipped — unlike passage on 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 after supabase.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 useEffect in sermons/app/page.tsx failed in production (2026-05-11 verification). Next.js client-side navigation replaced the URL before the effect ran, so ?confirmed=1 was 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 by SignupForm.tsx on the callback URL). This prevents the event from firing on every returning-user PKCE login (OAuth, magic link, re-auth).
  • Note: ?confirmed=1 is 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 after POST /api/sermons/log-first-visit returns fired: true.
  • Server gate: /api/sermons/log-first-visit does an atomic UPDATE profiles SET first_app_visit_at = now() WHERE first_app_visit_at IS NULL RETURNING id. Only the first call per user returns fired: 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/checkout fetch.
  • Properties: product: 'sermon_pro', billing: 'monthly' | 'annual'
  • distinct_id: Supabase UID

6. upgrade_completed

  • Where: src/app/api/stripe/webhook/route.ts — fires after checkout.session.completed is processed and subscription tier flipped to sermon_pro.
  • Properties: product: 'sermon_pro', billing (from session.metadata.billing)
  • distinct_id: Supabase UID
  • Deduplication: Uses posthog_dedup_events table to prevent double-firing on retries.
  • Billing source: session.metadata?.billing (stamped by checkout route) takes precedence over the stripePriceId.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_submitted client-side before auth)
  • Post-auth: Supabase UUID (used by all server-side events and client-side events after login)

Stitching happens at two surfaces:

  1. SignupForm.tsx — calls posthog.identify(userId) immediately after signUp() returns a user, BEFORE firing signup_form_submitted. This merges the anonymous browser session with the identified user.
  2. auth/callback → sermons/appauth/callback appends ?identify=<uid> to the redirect URL; sermons/app/page.tsx reads this param and calls posthog.identify(uid) in a useEffect. 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.ts
  • src/app/api/stripe/webhook/route.ts
  • src/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

SpecWhat it guards
e2e/contracts/posthog-funnel-events.contract.spec.tsEvery 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.tsLive PostHog API verification of all 6 events with real Supabase user (requires POSTHOG_PERSONAL_API_KEY + SUPABASE_SERVICE_ROLE_KEY)

Changelog

DateChangeNotes
2026-08-14Added seo_cta_clicked + generator_startedThe 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-11Removed legacy sermon_upgrade_click event from SermonUpgradeButton.tsxCanonical event is upgrade_clicked. Legacy event was duplicating the capture on every button click. No PostHog insight consumers existed; safe to remove.

Verification History

DateResultNotes
2026-05-115/6 events ✅, signup_email_confirmedFound 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-14seo_cta_clicked + generator_started shipped, NOT yet observed in PostHogWired 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.