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

How to read the funnel (2026-09-23 — use THIS order)​

Build the PostHog funnel from these steps, in order. Each one was verified ARRIVING in PostHog (project 297257) on 2026-09-23, not merely present in code.

StepEventCountsNotes
1 Arrived$pageview (host sermonwise.ai)every page viewbreak down by $pathname
2 Clicked "Generate"seo_cta_clickedevery generator link, every pagepage_cluster = page it happened on (template-occasion, passage-tradition, prep-desk, …); landing_cluster = the link's own from
3 Saw a previewpreview_shownsigned-out preview renderedpreview_requested / preview_limited / preview_failed explain the gap between requested and shown
4 Went to sign uppreview_signup_clicked or signup_cta_clicked
5 Has an accountfirst_app_visitexactly once per account, ever (DB-gated)Use this, NOT signup_email_confirmed (see warning below)
6 First sermonfirst_sermon_generatedserver, once per account
7 Clicked upgradeupgrade_clickedcheckout abandonment = step 7 without step 8
8 Paidupgrade_completedserver, Stripe webhook, deduped

⚠️ Do not use signup_email_confirmed as the sign-up count. It is emitted from THREE places (auth_trigger, log_first_visit, pkce_callback), so one confirmation is recorded 2–3 times, and the pkce_callback copy also fires on ordinary returning-user magic-link sign-ins (observed 2026-09-23). first_app_visit is the clean once-per-account signal.

Automated browsers are excluded from PostHog and GA4 (navigator.webdriver, PR #1580), so Playwright/headless runs never appear in these numbers — verify tracking in a real browser.

Google Analytics 4 / Google Ads (2026-09-23)​

  • GA4 events: sermon_cta_click, sermon_signup_click, sermon_preview_shown, and sign_up (fired with first_app_visit, once per account). Purchases reach GA4 server-side via the Measurement Protocol (GA4_API_SECRET is set in production).
  • Google Ads: SermonWise sign-ups fire a conversion only when NEXT_PUBLIC_GOOGLE_ADS_SERMONWISE_SIGNUP_LABEL is set (a SermonWise conversion action in Google Ads account AW-18306167903). Unset = no-op. It deliberately never falls back to the church-onboarding purchase label. Before this, no SermonWise action fired any Google Ads conversion. Alternative without code: import GA4 sign_up as a Google Ads conversion.

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_visitSermonAppFunnelPing.tsx (every /sermons/app/* page) + /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/components/sermons/SermonAppFunnelPing.tsx, mounted in src/app/sermons/app/layout.tsx so it runs on EVERY app page — fires client-side after POST /api/sermons/log-first-visit returns fired: true. (Moved from the dashboard page.tsx on 2026-09-23: since try-before-signup a new pastor lands on /sermons/app/new, so the dashboard-only ping — and the ?identify= stitch beside it — never ran on their first visit.) Also fires GA4 sign_up and, when configured, the SermonWise Google Ads conversion.
  • 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/app — auth/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-09-23seo_cta_clicked on EVERY generator link (delegated SermonFunnelTracker); new signup_cta_clicked, preview_*, preview_failed; identify + first_app_visit moved to the app layout; GA4 sign_up/CTA/preview events; SermonWise Google Ads sign-up conversion (env-gated)PostHog showed seo_cta_clicked = 0 in 30 days while /login took 47 views from 41 people: only 4 pages used SermonGeneratorCta; the templates, 340 passage×tradition pages and Prep Desk used plain links.
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_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-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.