# Paywalls: all entry points One SDK API handles every paywall: `analytics.paywall(...).view(...)`. Link an onboarding helper when the presentation belongs to that onboarding journey. Omit the link when it belongs to settings, a feature gate or another in-app entry point. Placement names do not decide the category, and no dashboard registration is needed. ## Link an onboarding paywall ```ts const onboarding = analytics.onboarding({ id: 'welcome', version: 1, steps: ['intro', 'goal'], }); const upgrade = analytics.paywall({ id: 'upgrade', version: 1 }); const discount = analytics.paywall({ id: 'discount', version: 1 }); await onboarding.start(); // Call these as each actual step is reached: await onboarding.step('intro'); await onboarding.step('goal'); // When this paywall becomes visible: const view = await upgrade.view({ placement: 'welcome', productIds: ['pro_monthly', 'pro_annual'], onboarding, }); ``` Do not record the same paywall again as a generic `paywall_viewed` event or an extra onboarding step. There is no `onboarding.paywall()` API. The paywall helper snapshots the flow ID, flow version and current attempt ID and carries them into later interactions. Call `onboarding.complete()` when onboarding actually finishes. A paywall may appear before or after that call: completion does not erase its attempt. The report follows each onboarding start for 24 hours, matching the existing onboarding funnel. A paywall after that window is excluded from this journey; an in-app presentation should omit the onboarding link. ## Additional offers If the user closes the main paywall and your app actually presents a different discount design: ```ts await view?.dismissed({ reason: 'close_button' }); const offer = await discount.view({ placement: 'exit_offer', productIds: ['pro_annual_discount'], onboarding, // Same attempt, separate paywall and presentation. }); ``` Define different designs with different IDs; increment a design's numeric version when it changes. Additional presentations are optional branches. People who never see a discount are not counted as having dropped out of a required step. The report API retains all, first and additional presentations. The simplified onboarding page no longer displays a separate paywall block. Equal earliest timestamps have unknown order and are excluded from the first/additional split, while remaining in the overall count. ## Present the same design elsewhere ```ts const settingsView = await upgrade.view({ placement: 'settings', productIds: ['pro_monthly', 'pro_annual'], }); ``` This is an in-app paywall even if the person completed onboarding earlier. Never attach a stale onboarding helper to a later settings or feature-gate presentation. Call `view` once when shown, not on each render. The app owns the paywall UI and purchasing; these calls only track behavior. A null tracking handle must not block either. ## What the reports measure - **Onboarding paywalls:** attempts started in the selected date range and exact selected flow/version. The funnel's Paywall viewed stage additionally requires all configured steps in order before the presentation, within 24 hours of the start. The private API's `reached_attempts` retains broader linked reach regardless of those preceding steps. Neither count requires `onboarding.complete()`; they can exceed completed attempts. These are attempts, not unique people. - **First / additional:** ordered by actual presentation time within each attempt. Reopening the same design is still another presentation. A person can appear in multiple paywall bars; do not sum them for the overall reached count. - **In-app paywalls:** presentations without an onboarding link, selected by their presentation date. Viewers are deduplicated using resolved analytics identity. A person viewing two placements counts once in the overall total, once in each relevant placement. The paywall/version selector filters this section only. - **Paywalls page:** Paywall viewers, Subscriptions started, and Conversion. The new `paywall_journeys.all` slices include valid onboarding and in-app presentations by **view date**, across **all** onboarding flows/versions. Viewers are resolved unique identities, deduplicated overall and within each exact design/version/placement/context row. A person can appear in several rows but counts once in the headline. This is not a sum of onboarding attempts and in-app viewers. - **Eligibility:** views from people with existing paid/trial access are excluded from new-subscription conversion in both contexts. An inactive client snapshot supplies negative evidence; earlier provider access can override it. Unknown access makes the applicable eligible count/rate unavailable, while observed starts and total raw viewers remain available. Earlier eligible presentations remain after a later subscription. The separate onboarding funnel retains its attempt-based journey definition. - **Conversion:** verified trial starters or direct purchasers, once per viewer across products and contexts, divided by eligible viewers. Trial/direct groups can overlap; do not add them. A later trial payment, expiration or renewal does not change subscription starts. Missing billing is unavailable, not zero. No eligible viewers means no rate. - **Global filters:** app/environment and inclusive `from`, exclusive `to` UTC dates apply. Chart intervals do not change these whole-period comparisons. Selecting a flow in Onboarding does not restrict the Paywalls page. Linked views must have a valid start and fall within its 24-hour window; broken links remain excluded. - **UI:** one flat horizontal conversion comparison by entry point. A small Onboarding label distinguishes linked presentations; the same placement in both contexts has separate rows. Multiple designs/versions expose one selector and secondary labels; one design hides the selector/repeated labels. Known rates sort descending with a fixed 0–100% scale and counts beneath the bars. A 50% conversion always fills half the track; changing the selected design does not rescale it. Unknown rates have neutral empty tracks and an em dash, not a zero-length measured result. Subscriber-only rows are omitted from the comparison; their exclusions remain in data details. - **Details:** clicking/tapping an entry point opens trial/direct starts, eventual trial outcomes and collapsed tracking diagnostics. The top info button holds definitions, incomplete-data status, raw/excluded counts, coverage and the per-app RevenueCat setup link. No visible trial strip, explanatory footer or duplicate expanded metric block. Checkout starts must match a presentation within 24 hours; subsequent results may resolve through report generation time. Repeated attempts remain diagnostics, not extra subscription starts. ## Onboarding funnel presentation Onboarding appears before Paywalls in the sidebar and mobile report selector. Its page contains the flow/version selector, compact outcome metrics and one funnel. The Funnel/Answers tabs, selected-step text footer, standalone paywall journey block and explanatory bottom footer are removed. Definitions, stage percentages and trial outcomes live in tooltips. Once a flow/version has any valid linked paywall history, its chart becomes **Started → configured steps → Paywall viewed → Subscription started**. The shape stays the same when switching date ranges. Flows without linked paywall history retain **Started → configured steps → Completed**. The new stages are derived from existing SDK events; no SDK release, manual paywall step, migration or extra tracking call is needed. The funnel reconstructs each attempt's ordered prefix, using the earliest matching occurrence of each next step at or after its predecessor. Every prefix event and the qualifying paywall view must be within 24 hours of the original start. **Record the configured steps before showing the linked paywall** for this path. Paywalls earlier in the journey or attempts with missing/out-of-order steps remain in private branch metrics but do not qualify for the final stages. The info tooltip reports the count of linked attempts excluded from this path. Arbitrary mid-flow paywall insertion is not implemented. Paywall viewed counts an attempt once across all qualifying presentations, including repeat views and optional discount offers. Subscription started counts the union of verified trial starters and direct purchasers once per attempt, using exact purchases from those qualifying views. A purchase on an earlier, nonqualifying view cannot be credited to a later view. Checkout intent and client success alone are not conversions. Later trial payment, expiration or renewal does not add/remove a subscription start; its trial outcome changes in the stage tooltip. These are observed RevenueCat matches, with partial-history coverage, not a claim of complete billing history. Missing/limited billing returns `null` and shows an unavailable gray stage rather than a 100% drop-off. **Onboarding completed** still means all configured steps followed by `onboarding.complete()` within 24 hours, divided by starts. **Subscribed** means qualifying subscription starts divided by the same starts. Completion is deliberately not a required step before paywall or subscription: the app may finish onboarding before or after purchase, or without buying. The private report retains `Report.onboarding` for completion and `paywall_journeys.onboarding.funnel` for the monetization path, including its conversions and excluded-attempt count. The Paywalls page also includes these presentations in its independent view-date, unique-viewer comparison across all flows. The two reports can differ in counts because their date selection and counting units differ. ## Verified conversions Paywall conversions now use authenticated RevenueCat billing evidence plus an exact SDK transaction match. They are **observed conversions**, not a claim of complete historical coverage. Unconnected billing and reporting limits show `null` (—); connected scopes with no observed matches show zero. Client success by itself never creates a paid customer. ### App setup 1. Complete the [RevenueCat connection and identity bridge](revenuecat-setup.md). Keep the bridge up to date after login/logout/account switches. 2. Track the actual paywall presentation and purchase attempt, using the exact purchased store product ID. 3. Supply the transaction ID returned by that **purchase call**, not a package/offering ID, an entitlement ID, a webhook event ID, or a transaction remembered from `CustomerInfo`. ```ts // selectedPackage is the actual RevenueCat package the user chose. const attempt = await view?.purchaseStarted({ productId: selectedPackage.product.identifier, }); // Keep your existing purchase error/cancellation handling. const purchased = await Purchases.purchasePackage(selectedPackage); await attempt?.finished({ result: 'succeeded', transactionId: purchased.transaction.transactionIdentifier, }); ``` Tracking failure must not block the purchase. Report cancelled/failed/pending outcomes through the same attempt; a pending purchase can resolve later using its saved handle. Do not track restore operations as new purchase attempts. When no transaction ID is available, leave it missing rather than guessing. No extra SDK API, mobile RevenueCat secret, webhook, migration or dashboard registration is needed. The Expo lab already supplies this ID. ### Existing subscribers and eligibility Keep recording every presentation, including accidental subscriber views. The backend classifies access **at the presentation time**, never using today's subscription state to erase an earlier free view. Active trials count as existing access too. Cancellation of auto-renewal alone does not remove access; actual subscription periods, grace periods and extensions matter. Pass the app's access state immediately before displaying a paywall: ```ts const view = await analytics.paywall({ id: 'upgrade', version: 1 }).view({ placement: 'settings', accessState: 'inactive', // 'active' | 'inactive' | 'unknown'; omit when unknown. }); ``` This is a **client-reported snapshot**, not verified billing or an authorization decision. With RevenueCat, use the current customer's `CustomerInfo.entitlements.active`, including trial, paid, family or lifetime access. Use the access state your app actually knows; do not hardcode inactive, infer it from a missing transaction, or reuse another account's/stale CustomerInfo. Unknown status must not block the paywall. The analytics SDK has no RevenueCat dependency and does not fetch billing itself. The lab refreshes CustomerInfo before recording each Test Store presentation. Reporting checks the snapshot against scoped provider history and resolved identity. A known active provider period overrides a stale inactive snapshot. Unknown/conflicting ownership, transfers, refunds without a clear access endpoint, malformed periods and missing snapshots stay unknown. A matched purchase from this exact presentation is not treated as pre-existing access, including tolerated device clock skew. Later revisits can be excluded while the original conversion stays counted. Missing billing history cannot prove free access. When any presentation's eligibility is unknown, eligible viewers and the overall rate show **—**; observed attributed starts remain visible, excluding known subscriber presentations. The compact access note discloses excluded subscriber views and unknown viewers. `views`, `viewers`, all raw events and purchase diagnostics remain complete. Subscriber/unknown viewer groups can overlap with eligible people who appeared at another time; do not subtract group counts from total viewers. This first version measures acquisition into one paid offering per app. A higher-tier upsell to an existing subscriber is excluded from this acquisition report; separate entitlement/tier upgrade reporting is not implemented. Existing events are not rewritten or backfilled with guessed access snapshots. ### Counts and rates - **Subscriptions started:** the union of matched trial starters and direct purchasers, deduplicated per viewer (or onboarding attempt in the API). This counts people who started, not the number of products or currently active subscriptions. A trial becoming paid, expiring or being cancelled does not add/remove a start; renewals do not add starts. A person in both trial and direct groups counts once, so the breakdown need not sum to the headline. - **In-app conversion rate:** subscriptions started ÷ unique eligible viewers in the selected paywall scope. For example, 100 eligible viewers with 10 trial starters and 2 other direct purchasers means 12 starts / 12%. If 4 trials later pay, starts and conversion stay 12 / 12%, while trial outcomes become 4 paid and 6 pending. No eligible viewers or unknown access means the rate is unavailable (—), not 0%. - **Started trial:** a matched RevenueCat initial transaction with `period_type: TRIAL`. Not inferred from a product name or successful callback. - **Paid directly:** a matched initial/non-renewing purchase with a positive provider price and no trial period. - **Paid after trial:** the original matched trial's subscription chain later has a verified positive payment. This updates the original paywall even if payment happens after the selected date range. A provider trial-conversion flag is not required when the chain proves the transition. - **Paid:** the union of direct and trial-paying people (or onboarding attempts), counted once. One person buying multiple products does not inflate the overall paid count. Direct and after-trial groups can overlap, so do not add them to reconstruct the total. - **Trial outcomes:** paid, pending, ended without observed payment, or unknown. Auto-renew cancellation alone stays pending; an actual expiration confirms ended. An extension can keep the trial pending. Conflicting ownership/history or unknown payment amounts keep the outcome unknown. Subsequent renewals do not add conversions; a later refund does not erase the fact that someone converted. Revenue handles those money movements separately. - **Rates:** trial starts ÷ reached; paid directly ÷ reached; all paid ÷ reached; paid after trial ÷ trial starters **so far**. Pending/unknown trials stay in that denominator. These are observed match rates, not mature-trial forecasts. The Onboarding funnel and legacy onboarding API branch use unique **attempts**. The Paywalls page uses unique **resolved viewers** for both onboarding and in-app presentations, with the same eligibility rule for both. An individual can appear in multiple placement bars, but only once in the combined total. With multiple trials for the same person/attempt, the display gives precedence to paid, then pending, unknown, and finally ended; trial outcome segments therefore add up to trial starters. Paywall counts use the selected presentation/onboarding-start cohorts; New business uses first-open cohorts; Revenue uses transaction dates. Their totals need not match. Products remain in Revenue; there is no second products chart here. ### Matching rules Reports read SDK events, identity links, mapped webhook receipts and recovered REST history in one repeatable-read database snapshot. Matching is scoped to analytics app/environment and RevenueCat connection/app/store/environment. Provider current/original customer IDs and aliases must resolve to exactly one analytics subject matching the SDK presentation. The store transaction ID and product ID must match exactly. Checkout starts must be within 24 hours of the presentation. The provider purchase time must be between the checkout start and its recorded result, allowing up to five minutes of device/server clock skew. A late pending→success result can settle after 24 hours; trial lifecycle follow-up has no 24-hour cut-off. Future-dated provider periods wait until their start. Only one SDK purchase attempt may claim an economic transaction. Competing claims are checked across **all observed dates and flows before filtering**, not just visible rows. Duplicate SDK deliveries, webhook retries and duplicate API/webhook economic transactions count once. Conflicting receipts/identities, family-shared access, ordinary renewals, and missing IDs remain uncredited. There is no nearest-time or last-paywall fallback. A zero-price non-trial purchase is not paid. An unknown amount is not assumed positive. Client linkage remains behavior evidence from a public-key SDK; it is not authentication or authorization. Billing itself must come from the trusted provider integration. The native RevenueCat Test Store path is verified. Apple/Google sandbox end-to-end attribution and Android product/base-plan identifier behavior still need platform verification; identifiers are deliberately not rewritten heuristically. ## Tracking coverage Older placement/version-only events still appear in raw history and legacy report totals, but cannot participate in the new comparison without a stable paywall and presentation ID. Broken onboarding links are excluded, never silently relabeled as in-app. Conflicting presentations and unmatched interactions are disclosed in the tracking coverage disclosure inside the info popover. Tracking coverage also shows matched/unmatched purchase results across the selected all-paywall presentation window and the selected onboarding-start cohort, missing transaction IDs, and conflicting claims. The provider-start coverage count uses purchase dates in the selected range across all flows, including purchases without any SDK callback. It is separate from presentation-cohort conversion counts and is not filtered by the local paywall selector. Missing old client IDs cannot be recovered by syncing billing alone. The report caps behavior history, provider history and identity links at 50,000 rows each, and results at 200 paywall variants/placement groups. Hitting a billing limit leaves behavior visible but conversions unavailable; hitting a behavior limit hides the entire comparison. No report silently truncates counts. Attribution recomputes on refresh when late evidence arrives; no additional collection endpoint, provider API request, migration or queue is needed. See [SDK reference](/docs/sdk.md), [tracking plan](/docs/tracking-plan.md) and [RevenueCat setup](/docs/revenuecat-setup.md).