# Analytics event ownership

Analytics is divided between controller-owned lifecycle events, explicit shared helpers, transactional server conversions, and trusted server claims.

<!-- funnelsgrove:generated:start contract-v3/contract/analytics-events -->
Step contract version: `3`

Contract hash: `d761e91d5ac6ff9e72c6d49c5bcd014270912473a66f99c49d726c65998085cf`

| Contract key | Public event | Raw event | Allowed step types | Allowed identities | Owner | Automatic | Dedupe | Required metadata |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| `add_payment_info` | `add_payment_info` | `add_payment_info` | `paywall_offer`, `upsell_offer`, `checkout` | `any` | `named-helper` | `false` | `event-id` | `funnelId`, `funnelVersionId`, `environment`, `stepContractVersion`, `stepId`, `stepName`, `stepType`, `eventId` |
| `checkout_completed` | `checkout_completed` | `checkout_completed` | `paywall_offer`, `upsell_offer`, `checkout` | `any` | `named-helper` | `false` | `event-id` | `funnelId`, `funnelVersionId`, `environment`, `stepContractVersion`, `stepId`, `stepName`, `stepType`, `eventId` |
| `checkout_started` | `checkout_started` | `checkout_started` | `paywall_offer`, `upsell_offer`, `checkout` | `any` | `named-helper` | `false` | `event-id` | `funnelId`, `funnelVersionId`, `environment`, `stepContractVersion`, `stepId`, `stepName`, `stepType`, `eventId` |
| `email_captured` | `email_captured` | `email_captured` | `form_input` | `email-capture` | `transactional-server` | `false` | `logical-email-conversion` | `funnelId`, `funnelVersionId`, `environment`, `stepContractVersion`, `stepId`, `stepName`, `stepType`, `projectId`, `funnelEndUserId`, `eventId` |
| `first_step_clicked` | `first_step_clicked` | `first_step_clicked` | `any` | `any` | `controller` | `true` | `step-visit` | `funnelId`, `funnelVersionId`, `environment`, `stepContractVersion`, `stepId`, `stepName`, `stepType` |
| `first_step_viewed` | `first_step_viewed` | `first_step_viewed` | `any` | `any` | `controller` | `true` | `step-visit` | `funnelId`, `funnelVersionId`, `environment`, `stepContractVersion`, `stepId`, `stepName`, `stepType` |
| `funnel_completed` | `funnel_completed` | `funnel_completed` | `purchase_completed` | `any` | `terminal-helper` | `false` | `funnel-run` | `funnelId`, `funnelVersionId`, `environment`, `stepContractVersion`, `stepId`, `stepName`, `stepType`, `eventId` |
| `funnel_started` | `funnel_started` | `funnel_start` | `any` | `any` | `controller` | `true` | `funnel-run` | `funnelId`, `funnelVersionId`, `environment`, `stepContractVersion` |
| `purchase_completed` | `purchase_completed` | `purchase_completed` | _None_ | _None_ | `trusted-server` | `false` | `provider-payment` | `funnelId`, `funnelVersionId`, `environment`, `stepContractVersion`, `projectId`, `funnelEndUserId`, `eventId`, `providerPaymentId`, `purchaseKind` |
| `registration_completed` | `registration_completed` | `registration_completed` | _None_ | _None_ | `trusted-server` | `false` | `subscription-claim` | `funnelId`, `funnelVersionId`, `environment`, `stepContractVersion`, `projectId`, `funnelEndUserId`, `eventId`, `subscriptionClaimId` |
| `step_completed` | `step_completed` | `step_end` | `any` | `any` | `controller` | `true` | `step-visit` | `funnelId`, `funnelVersionId`, `environment`, `stepContractVersion`, `stepId`, `stepName`, `stepType` |
| `step_engaged` | `step_engaged` | `step_engaged` | `any` | `any` | `controller` | `false` | `step-visit` | `funnelId`, `funnelVersionId`, `environment`, `stepContractVersion`, `stepId`, `stepName`, `stepType`, `durationMs` |
| `step_exited` | `step_exited` | `step_exit` | `any` | `any` | `controller` | `true` | `step-visit` | `funnelId`, `funnelVersionId`, `environment`, `stepContractVersion`, `stepId`, `stepName`, `stepType` |
| `step_started` | `step_started` | `step_start` | `any` | `any` | `controller` | `true` | `step-visit` | `funnelId`, `funnelVersionId`, `environment`, `stepContractVersion`, `stepId`, `stepName`, `stepType` |

Quiz completed counts each unique user who reaches a paywall step. `purchase_completed` remains trusted-server-only purchase verification and does not define Quiz completed. Browser terminal reach and `funnel_completed` remain lifecycle-only and are not purchase conversions.

The `purchase_completed` event is owned by `trusted-server` and is recorded only after the backend verifies a successful initial purchase. The separate `registration_completed` event remains owned by `trusted-server` for the later app-link claim. The `email_captured` event is owned by `transactional-server` and uses `logical-email-conversion` deduplication.
<!-- funnelsgrove:generated:end contract-v3/contract/analytics-events -->

## Ownership rules

- The flow controller emits lifecycle events. Step components do not duplicate them.
- `step_engaged` is non-automatic and absent by default; only an explicit, centralized, tested policy may emit it through the approved controller/helper boundary.
- `submitEmailCapture` creates `email_captured` transactionally, then routes only after success.
- Shared payments components emit checkout semantic events at real payment boundaries when given `checkoutAnalytics`: dialog/wallet intent owns `checkout_started`, actual submit owns `add_payment_info`, and verified success owns `checkout_completed`. Funnel components do not call those SDK methods directly.
- The terminal helper owns funnel completion.
- A trusted backend owns `purchase_completed` after verifying an initial subscription or one-time payment; browser code never emits it. This purchase event is independent from the paywall-reach definition of Quiz completed.
- A separate trusted server claim owns `registration_completed` when the purchased subscription is later linked in the app.
- Provider adapters and the durable outbox own PostHog and server Meta delivery.
- Stripe return URLs use `trackStripePaymentCheckoutReturn` only for deduplicated `payment_checkout_succeeded` / `payment_checkout_returned` diagnostics. Browser query parameters never prove a purchase.

## Privacy boundary

- Never put raw email or other PII in analytics event metadata or logs.
- Only the server delivery boundary may use raw email as Meta hashing input.
- PostHog properties must remain non-PII.

## Completion versus exit

Completion means the step’s intended action succeeded and navigation was committed. Back, close, unload, replaced visits, and abandoned payment are exits. The controller deduplicates each visit outcome. Preview suppresses API/provider delivery.

## Email capture sequence

1. Call `submitEmailCapture` once for the in-flight submit.
2. On API failure, do not mutate answer state, lifecycle, or route.
3. On a newly created conversion, browser Pixel/Google may fan out with the server event ID.
4. On a duplicate, skip browser fanout and continue safely.
5. Never call `trackLead`, PostHog, Meta CAPI, or a provider from the email step.

Use the [analytics QA checklist](../qa/analytics.md) for journey-level evidence.
