Onboarding: paywall after the tour
DraftCode ships in the first store build after ENG-4373 merges; launch once that build is live in both stores
Decision
Pending. The ship rule was agreed before launch (Steve, 2026-10-02), under Calling it.
Do not go back to
Pending.
Hypothesis
App 1.0.8 opened the paywall right after the guided tour, a median 36 seconds after profile completion and before the first question. Guided-path users sending a first prompt within 24 hours of profile completion fell from 42.0% on 1.0.7 to about 23% on 1.0.8, while the paywall sold to 12 of 3,405 viewers from 2 Sep to 1 Oct (PostHog, read 2026-10-02).
Removing the onboarding paywall should raise the share of guided-path users who send a prompt within 24 hours. 1.0.8 also changed other guided-path screens, and users who see the paywall differ from those who do not by start path, so only a randomised test separates the paywall's effect.
Redesign (ENG-4373, 2026-10-28). Before launch, the default flipped: no paywall shows during onboarding unless a user is in the test arm. A new account now reaches the start chooser and the tour straight after sign-in, and the profile waits for its second send, so the decision now comes before the profile. The test arm's paywall opens on a plan and billing period read from the flag payload, so the offer can change without a store build. Steve (2026-10-28): "the first experiment will likely be monthly starter paywall vs no paywall". The question is now whether a cheap, preselected offer after the tour sells enough to justify any cost to activation.
Why this design
- A randomised test, not a before/after read. 1.0.8 changed other guided-path screens in the same release, and paywall viewers differ from non-viewers by start path, so the 1.0.7 to 1.0.8 drop cannot be pinned on the paywall from history alone.
- Only this one moment differs. Every other paywall stays, so the test asks whether the paywall after the tour pays for itself, not whether paywalls do. A
controluser still meets one at the limit. - The offer lives in the payload. Plan and billing period are read from the
paywallvariant's payload when the decision runs, so a follow-up offer is a console change, not a store build. Changing the payload mid-run changes the treatment; start a new experiment instead. - Exposure at the decision, not on render. The hook that decides is mounted for every signed-in user, so a render-time exposure would enrol everyone at app start, including users who never reach the end of a tour. The manual mode of
useExperimentfires it where the arms actually diverge. - The turn is spent at the decision, in both arms. Marking it on dismissal would give a
controluser, or one who backgrounds the app mid-sheet, a second decision later. - The arm is stored per user, write-once. The primary metric's event (
chat.prompt_submitted) fires long after the decision, so it reads the stored arm rather than re-reading a flag that can reload or change. - No
*-controlsibling file. The control is not a screen but the unchanged paywall timer, so the arm is a branch inrunOnboardingPaywallDecision, which is tested directly. - Activation is the primary metric, purchases the guardrail. At about 3.5 purchases per 1,000 viewers, neither arm can reach significance on revenue at this sample size; 24-hour prompts can. The ship rule still caps the revenue the treatment may give up.
Result
Not launched.
Full write-up The two arms · Assignment and exposure · Metrics · Calling it · PostHog configuration
The two arms
Both arms start at the same decision: a guided tour finishes or is skipped, and the user is on the Free plan, in an individual workspace, with a loaded store offering, and has not had their turn on this install. Both arms mark the user as having had their turn.
control: nothing is scheduled. No sheet, no keyboard dismissal. Every other paywall (limit hit, settings, chat usage, credit top-ups) is unchanged, so a control user still meets the paywall when they run out.
paywall: after 700 ms the keyboard is dismissed and the paywall sheet opens with trigger: 'onboarding', selecting the plan and billing period in the flag payload, { "plan": "starter" | "pro", "cycle": "monthly" | "yearly" }. A missing or unreadable payload opens on the onboarding default, Starter monthly.
There is no *-control sibling file: both arms are a branch in runOnboardingPaywallDecision in src/lib/onboarding-paywall-decision.ts, called from use-onboarding-paywall.ts.
Assignment and exposure
experiment.exposedfires once per user, at the decision, in both arms, withexperiment: 'onboarding-paywall',variantand$feature/onboarding-paywall. It uses the manual mode ofuseExperiment: the deciding hook is mounted for every signed-in user, so a render-time exposure would enrol everyone at app start.- Unbucketed users (flag not loaded,
falsebecause it is missing, disabled or not launched, or an undeclared variant such asskippedor PostHog's defaulttest) see no paywall, fire no exposure and store no variant. - The arm is stored per user on the device (
onboarding.paywall_variant.<userId>, write-once) and stamped asexperiment_varianton every laterchat.prompt_submittedfrom that user. - Users are identified long before the decision, so assignment is per person.
- Builds before ENG-4373 read the arms the old way:
controlthere opens the paywall and fires an exposure forcontrol. The flag must only enrol app versions with ENG-4373 (release condition on$app_version, see PostHog configuration), or the control arm mixes in users who saw a paywall. - Like today's paywall, the decision can follow a Settings tour replay or a returning user's guided tour.
Metrics
Every metric step except the exposure is filtered on platform = mobile. The exposure event is filtered on experiment = onboarding-paywall and $environment = production.
| Slot | Metric | Definition | Window |
|---|---|---|---|
| Primary | Prompt within 24h | Funnel: exposure → chat.prompt_submitted | 24 hours |
| Secondary | Purchase 7d | Funnel: exposure → action Purchase — any surface (canonical) | 7 days |
| Secondary | Purchase 14d | As above | 14 days |
| Secondary | Return day 1-7 | chat.prompt_submitted on a later calendar day | 7 days |
| Secondary | Limit-hit paywall 14d | Funnel: exposure → paywall.viewed where trigger = limit_hit | 14 days |
| Secondary | Limit-hit purchase | Funnel: paywall.viewed (trigger = limit_hit) → action Purchase — any surface (canonical) | 14 days |
| Secondary | AI answer within 24h | Funnel: exposure → chat.ai_response_received | 24 hours |
The table is the design from ENG-3808. With the arms reversed, Sandro sets the primary metric before launch: purchases (7d or 14d) fit the "monthly Starter vs no paywall" question better than 24-hour prompts, which become the guardrail. The control baseline for 24-hour prompts was about 24% on 1.0.8, before the profile moved to the second send; expect it to change.
Purchases use Growth's existing action Purchase — any surface (canonical) (credits.topup_payment_success, paywall.purchase_completed, subscription.purchased, subscription.payment_success), filtered on platform = mobile in the metric. Its overlapping events do not double count: a funnel counts users, not events.
Purchase events carry no trigger, so the limit-hit purchase metric is a time-windowed approximation, not attribution.
Breakdowns when reading: $os, country (Lebanon against the rest), persona, and whether the user registered within 24 hours before exposure.
Calling it
Run until 1,000 exposed per arm and at least 3 full weeks. Do not call it early.
Ship rule: to be agreed before launch (Steve and Sandro). The 2026-10-02 rule was written for the old arms (remove the paywall if skipped raised 24-hour prompts) and no longer applies.
Limits worth knowing before reading the result:
- At about 3.5 purchases per 1,000 viewers, each arm sees single-digit purchases at 1,000 exposed. The purchase leg of the ship rule is a directional check, not a significant one.
- 1,000 per arm at a 24% baseline detects roughly a 5-point absolute lift. The 1.0.8 drop was about 19 points, so a large paywall effect will show; a small one may not.
PostHog configuration
- Feature flag
onboarding-paywall: multiple variants, user-level,control50% /paywall50%. Rename the oldskippedvariant topaywall; the app treatsskippedas unbucketed. Set thepaywallpayload, e.g.{"plan":"starter","cycle":"monthly"}. Add a release condition$app_versionat or above the first build with ENG-4373, so older builds stay unbucketed. Before launch, release conditions only for test accounts, each with a variant override. - Experiment
Onboarding: paywall after the tour, linked to the existing flag. Exposure: custom eventexperiment.exposed, filtered as above. Exclude multivariate users, filter out internal and test users, Bayesian at 95%, Replay Vision off. Metrics as in the table. Save as draft. - Verify on a device with each test account: one exposure per arm,
controlshows nothing,paywallopens on the payload's plan and billing period, later prompts carryexperiment_variant; with no override, nothing shows and nothing is exposed. - Launch once the build is live in both stores: replace the test release conditions with one set for all users at 100%, launch, tell Steve the date, and tell Growth the flag key (their Experimentation Health dashboard lists only
exp_flags).
Kill switch: set the flag to 100% control, or turn it off. Every user not yet at the decision gets no onboarding paywall, with no release.