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.
| Step | Event | Counts | Notes |
|---|---|---|---|
| 1 Arrived | $pageview (host sermonwise.ai) | every page view | break down by $pathname |
| 2 Clicked "Generate" | seo_cta_clicked | every generator link, every page | page_cluster = page it happened on (template-occasion, passage-tradition, prep-desk, …); landing_cluster = the link's own from |
| 3 Saw a preview | preview_shown | signed-out preview rendered | preview_requested / preview_limited / preview_failed explain the gap between requested and shown |
| 4 Went to sign up | preview_signup_clicked or signup_cta_clicked | ||
| 5 Has an account | first_app_visit | exactly once per account, ever (DB-gated) | Use this, NOT signup_email_confirmed (see warning below) |
| 6 First sermon | first_sermon_generated | server, once per account | |
| 7 Clicked upgrade | upgrade_clicked | checkout abandonment = step 7 without step 8 | |
| 8 Paid | upgrade_completed | server, 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, andsign_up(fired withfirst_app_visit, once per account). Purchases reach GA4 server-side via the Measurement Protocol (GA4_API_SECRETis set in production). - Google Ads: SermonWise sign-ups fire a conversion only when
NEXT_PUBLIC_GOOGLE_ADS_SERMONWISE_SIGNUP_LABELis 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 GA4sign_upas 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.
| # | 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 | SermonAppFunnelPing.tsx (every /sermons/app/* page) + /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/components/sermons/SermonAppFunnelPing.tsx, mounted insrc/app/sermons/app/layout.tsxso it runs on EVERY app page — fires client-side afterPOST /api/sermons/log-first-visitreturnsfired: true. (Moved from the dashboardpage.tsxon 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 GA4sign_upand, when configured, the SermonWise Google Ads conversion. - 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-09-23 | seo_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-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. |