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 6 Funnel Events

#EventFires FromSidedistinct_id
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

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-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)