Atlas

HAQQ Mobile Experiments

A/B test log
aa8df08

chat-mobile · PostHog

Mobile Experiments

One file per experiment, written when it is set up and finished when it concludes. This directory exists for a single reason: so a flow we tested and rejected stays rejected. Six months from now the losing variant will look like a good idea again to someone who wasn't here — this is what tells them it already lost, and by how much.

2Experiments
0Running
1Draft
1Concluded

At a glance

Running first, then drafts, then concluded. Each name jumps to its write-up.

ExperimentStatusRanOutcome
Onboarding: paywall after the tour
onboarding-paywall
Draftnot launchedPending.
Onboarding: stepped form
onboarding-stepped-form
Concluded2026-08-27 → 2026-09-10Winner: stepped (+5.72% completion, > 99.9%)
onboarding-paywall

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

platform
mobile (chat-mobile)
ran
not launched
arms
control (no paywall after the tour, the default since ENG-4373) · paywall (the paywall after the tour, on the plan and billing period in the flag payload)
primary metric
to be set by Sandro before launch (proposal under Metrics)
exposures
none yet

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 control user still meets one at the limit.
  • The offer lives in the payload. Plan and billing period are read from the paywall variant'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 useExperiment fires it where the arms actually diverge.
  • The turn is spent at the decision, in both arms. Marking it on dismissal would give a control user, 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 *-control sibling file. The control is not a screen but the unchanged paywall timer, so the arm is a branch in runOnboardingPaywallDecision, 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.exposed fires once per user, at the decision, in both arms, with experiment: 'onboarding-paywall', variant and $feature/onboarding-paywall. It uses the manual mode of useExperiment: 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, false because it is missing, disabled or not launched, or an undeclared variant such as skipped or PostHog's default test) 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 as experiment_variant on every later chat.prompt_submitted from that user.
  • Users are identified long before the decision, so assignment is per person.
  • Builds before ENG-4373 read the arms the old way: control there opens the paywall and fires an exposure for control. 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.

SlotMetricDefinitionWindow
PrimaryPrompt within 24hFunnel: exposure → chat.prompt_submitted24 hours
SecondaryPurchase 7dFunnel: exposure → action Purchase — any surface (canonical)7 days
SecondaryPurchase 14dAs above14 days
SecondaryReturn day 1-7chat.prompt_submitted on a later calendar day7 days
SecondaryLimit-hit paywall 14dFunnel: exposure → paywall.viewed where trigger = limit_hit14 days
SecondaryLimit-hit purchaseFunnel: paywall.viewed (trigger = limit_hit) → action Purchase — any surface (canonical)14 days
SecondaryAI answer within 24hFunnel: exposure → chat.ai_response_received24 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

  1. Feature flag onboarding-paywall: multiple variants, user-level, control 50% / paywall 50%. Rename the old skipped variant to paywall; the app treats skipped as unbucketed. Set the paywall payload, e.g. {"plan":"starter","cycle":"monthly"}. Add a release condition $app_version at 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.
  2. Experiment Onboarding: paywall after the tour, linked to the existing flag. Exposure: custom event experiment.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.
  3. Verify on a device with each test account: one exposure per arm, control shows nothing, paywall opens on the payload's plan and billing period, later prompts carry experiment_variant; with no override, nothing shows and nothing is exposed.
  4. 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.

onboarding-stepped-form

Onboarding: stepped form

Concludedstepped won and is the only flow in code

platform
mobile (chat-mobile)
ticket
ENG-2743
ran
2026-08-27 → 2026-09-10
arms
control (single-screen form, as shipped) · stepped (one question per step)
primary metric
onboarding completion rate
exposures
control 1,849 / stepped 1,808 (3,657 total, 50.6% / 49.4%)

Decision

stepped shipped. The flag was released to 100% stepped on conclusion, then the control arm was deleted from code: ProfileControlScreen, the ProfileProfessional second screen and route, the RoleSelector it alone used, the registry entry, and the experiment_variant threading on profile_completed. The stepped screen is now screens/main/profile/create.tsx; its state hook and step table live in screens/main/profile/stepped/. The flag can be archived in PostHog.

Do not go back to

The single-screen profile form (control): name, country, region, phone, role and referral on one screen, plus a second screen of work fields for legal professionals. It lost by 5.72% relative on completion with > 99.9% chance to win over 3,657 exposures. Presenting every field at once, with the phone number visible before any commitment, is the rejected shape. Do not re-propose it, and do not "simplify" the stepper back into it without a new experiment that beats this result.

Hypothesis

The current profile setup presents every field at once: name, country, phone, role, and a referral field — and for legal professionals a second screen with four more. A long form on a phone reads as work before the user has seen any value, and phone number in particular is the field users refuse first.

Breaking it into single-purpose steps should raise completion because each screen asks one easy thing, progress is visible, and the highest-friction field is not encountered until the user has already invested two steps.

The risk is the opposite effect: more screens means more places to quit, and a stepper that feels long is worse than a form that looks short. That is what the step-level funnel below exists to detect.

Why this design

  • One question per step. A long form on a phone reads as work before the user has seen any value; single-purpose screens make each step cheap and progress visible.
  • Friction rises only as commitment does. Name first because it costs nothing, role second because it is one tap and tailors everything after it, phone last of the required set because it is the field users refuse first.
  • Role early buys shorter paths. Asking it second means help seekers never see the work fields: 3 steps for them, 4 for professionals.
  • Referral stays folded into the last step. It is optional and most users skip it, and a step most people skip is a step most people can quit on.
  • The step indicator only ever grows. The total is unknown until role is picked, so it assumes 3 and adds a fourth dot for professionals; a count that shrank would read as the flow being cut short.
  • Exposure on render, in both arms. Profile setup is the surface itself, so rendering it is the moment the arms diverge.
  • Completion decides, guardrails can veto. A completion win that cost activation, revenue, referral capture or org data would not have shipped.

Result

Read from PostHog on 2026-09-11, Bayesian at 95%, conclusion Won.

ArmExposedCompletedRateΔ
control1,8491,42076.96%—
stepped1,8081,47081.36%+5.72% (relative)

Significance: chance to win > 99.9%, significant, 95% credible interval on the delta [2.20%, 9.24%]. The interval never crosses zero, so even the pessimistic end is a real lift.

Guardrails (activation, 7-day revenue, referral capture, time to complete, org-field completeness): none regressed materially at the time of the call.

Step funnel (stepped arm): not recorded here. The diagnostic funnel remains available in PostHog under the experiment's insights if a future onboarding change wants a baseline.

Full write-up The two arms · Metrics · PostHog configuration

The two arms

control — unchanged. ProfileCreate (name, country, phone, role, referral) and, for legal professionals, ProfileProfessional (position, org name, type, size, referral).

stepped — one question per step, ordered so friction rises only as commitment does:

#Step idAsksWhy here
1nameFirst + last nameThe gentlest possible opener. Typing your own name costs nothing and feels like nothing is being given away, so the first screen never reads as a demand.
2roleLegal professional / need legal helpA single tap, and still early enough to tailor everything after it. Confirmed with Continue like every other step, so the flow never behaves inconsistently.
3contactCountry + phoneThe highest-friction field, deliberately last of the required set — by now two steps are already invested.
4workPosition, organisation, type, sizeLegal professionals only. Highest investment, asked when commitment is highest.

The referral disclosure stays folded into the final step of whichever path the user is on, collapsed, exactly as ENG-2708 shipped it. It does not get its own step: it is optional, most users skip it, and a step most people skip is a step most people can quit on.

Path lengths differ by design — 3 steps for a help seeker, 4 for a professional. Asking role early is what buys that; asking it late would mean showing professionals' fields to everyone.

Because role is step 2, the total is not known while the user is on steps 1–2. The indicator assumes the shorter path (3) and animates a fourth dot in if the user picks legal professional. It moves at most once, and only ever grows — a count that shrank would read as the flow being cut short.

Metrics

Primary — decides the winner

Onboarding completion rate: onboarding.profile_completed ÷ experiment.exposed.

This is the whole point of the change and the tightest loop around it. Everything else is diagnostic or a guardrail.

Diagnostic — tells you why, does not decide

Step funnel: onboarding.step_viewed broken down by step_id. Stepped arm only — the control has no equivalent step boundaries, so this diagnoses where the stepped flow bleeds and can never compare the two arms. Cross-arm comparison happens on profile_completed.

This is the most valuable thing on the dashboard. If stepped loses, this says exactly which screen bled users; if it wins, it says which step to attack next. Break down by step_id, never by index — the seeker path and the professional path have different lengths, so index 3 is contact for one population and work for another, and a funnel built on the index silently averages two different flows together. (Same reasoning as tour_step_viewed.)

Also worth watching per step:

  • onboarding.step_back — returning to a previous step signals a step that asked something unclear, or one the user wasn't ready for.
  • Validation errors per step — a step with a high error rate is a step with a bad field, not a bad position in the flow.

Guardrails — can veto a win

A completion win that costs any of these is not a win:

GuardrailEventWhy it can veto
Activationfirst message sent within 24h of signupCompletion is worthless if the extra steps produce users who never engage.
Revenuesubscription.purchased within 7 daysThe reason this experiment exists.
Referral captureprofile_completed where referral_code_used = trueBurying referral in the last step must not kill the growth loop.
Time to completemedian experiment.exposed → profile_completedA stepped flow that takes noticeably longer is a worse experience even if more people finish.
Data quality% of professionals with org fields populatedIf work is where people quit, we win completion and lose the firm data sales needs.

Calling it

Ship stepped only if all of:

  1. Completion rate is higher, and PostHog reports significance (do not call it early — the number swings hardest in the first days).
  2. No guardrail regressed materially.
  3. The step funnel shows no single step bleeding badly enough to be worth fixing before rolling out.

If completion is flat but the step funnel is clean and time-to-complete is equal, prefer stepped anyway — it is the better platform for future onboarding changes. Say so explicitly in the decision rather than calling a tie.

Assignment caveat: PostHog initialises regardless of App Tracking Transparency — only identify() is ATT-gated. So a user who denies tracking is still bucketed and still fires exposure, but on an anonymous per-install distinct id rather than their user id. Assignment for them is per install, not per person: reinstalling or using a second device can land them in the other arm. Exposure counts should still track signups closely; a large shortfall means flags are failing to load, not that ATT is filtering people out.

Genuinely unbucketed users — PostHog disabled by env, or flags not yet loaded — render control and fire no exposure, so they land in neither arm.

PostHog configuration

Events this experiment reads

Event names are namespace.event — the adapter joins them, so search PostHog for the joined form.

EventFired byKey properties
experiment.exposedboth arms, when profile setup rendersexperiment, variant, $feature/onboarding-stepped-form
onboarding.step_viewedstepped onlystep_id, step_index, step_count, direction
onboarding.step_backstepped onlystep_id
onboarding.persona_selectedboth armspersona
onboarding.profile_completedboth armsexperiment_variant, referral_code_used, persona, signup_method, country, locale
onboarding.start_choice_viewedboth arms (next screen)variant
chat.prompt_submittedactivation guardrail—
subscription.purchasedrevenue guardrail—

variant is the authoritative record of what was rendered, not $feature/.... The SDK appends its flag properties after the ones we pass, so it overwrites our explicit $feature/onboarding-stepped-form with whatever the flag says at capture time. Our variant property survives, and it holds the frozen value the user actually saw. If the two ever disagree — possible across an anonymous → identified transition — variant is the truth and PostHog's attribution is not. Break down on it when auditing.

step_viewed fires in the stepped arm only. The step funnel is therefore a diagnostic for that arm, not a cross-arm comparison — the control has no equivalent step boundaries. Cross-arm comparison happens on profile_completed.

Wizard settings

  1. Description — name Onboarding: stepped form, flag key onboarding-stepped-form.
  2. Variant rollout — rename PostHog's default test to stepped; leave control. User-level participants, 100% rollout, 50/50 split.
  3. Analytics —
  4. Include people when: custom event experiment.exposed, not the default "Feature flag is called". Ours fires on render, which is the denominator the arms actually share.
  5. Filter that event to experiment = onboarding-stepped-form using the filter control beside the event picker. This is required, not optional: the SDK stamps $feature/<flag> onto every event for every loaded flag, so once a second experiment also emits experiment.exposed, its exposures would carry this flag's property too and be counted as exposures here. The experiment property is what scopes the event to one experiment.
  6. Multiple variant handling: Exclude multivariate users (the default). A person exposed anonymously and again after identification can land in different arms; someone who saw both tells us nothing, so drop them rather than pick one.
  7. Filter out internal and test users: on.
  8. Replay Vision: leave off. Session replay is already installed, so replays can be filtered by experiment.exposed → variant by hand. The scanner costs credits and keeps running after the experiment ends, which is an easy thing to forget.

Where the platform filter goes — and where it does not.

Filters belong in the metric definition, never applied ad hoc while reading results: PostHog derives significance from the definition, so a filter added at viewing time changes the chart but not the maths behind it.

Filter every step of every metric on the platform under test:

StepFilter
experiment.exposedNo platform filter — mobile-only event. Keep the experiment filter.
onboarding.profile_completedYes. Shared-map event; the web apps fire it too.
chat.prompt_submittedYes. Also fired by the web chat app.
subscription.purchasedYes. A no-op today (mobile-only via RevenueCat), but it costs nothing and holds if web ever adopts it.

The experiment touches no web surface, so cross-platform behaviour is identical in both arms and the comparison is unaffected either way — what filtering buys is that each number means what its label says. A dashboard where some steps are scoped and others are not is the kind of thing that gets misread months later, when whoever reads the result was not in this conversation.

The one thing given up: a user who completes onboarding on mobile and sends their first prompt on web reads as un-activated. Rare, and equally rare in both arms.

Filter value — a time-bounded exception. The rule forbids $lib filters, and the mobile client now registers { platform: 'mobile' }. But that property only exists on events from a build carrying it, so until the release that ships this PR reaches users, platform matches nothing and $lib = posthog-react-native is the only filter that works.

Use $lib to configure and verify before launch. Once the release is out, and before reading any result, swap the $lib filters on the shared-map steps — the three marked "Yes" in the table above — to platform = mobile.

experiment.exposed is not part of that swap: it stays on its experiment filter and takes no platform filter at all, as the table says. Delete this paragraph once the swap is done.

Metrics to attach

Attach guardrails as secondary metrics on the experiment rather than building standalone insights: PostHog then does variant attribution off the exposure event itself, instead of relying on you to break every insight down by flag correctly.

SlotMetricDefinition
PrimaryOnboarding completionFunnel experiment.exposed → onboarding.profile_completed
SecondaryActivationFunnel onboarding.profile_completed → chat.prompt_submitted, 24h window
SecondaryRevenueFunnel onboarding.profile_completed → subscription.purchased, 7d window
SecondaryReferral captureonboarding.profile_completed where referral_code_used = true
SecondaryReached start choiceFunnel onboarding.profile_completed → onboarding.start_choice_viewed

Two guardrails named above are deliberately not attached as experiment metrics:

  • Time to complete — PostHog experiment metrics have no duration type. Read it as a standalone insight: median time from experiment.exposed to onboarding.profile_completed, broken down by variant.
  • Data quality (org-field completeness) — read as a standalone insight too: onboarding.profile_completed where persona = legal_pro, comparing the share with organization_name set, by variant.

Both are eyeball checks before calling the result, not automated vetoes. Anything listed as a veto criterion that has no definition here is not enforceable — say so rather than implying it is being watched.

Diagnostic insight (not an experiment metric)

One funnel, stepped arm only, to find the bleeding step:

onboarding.step_viewed where step_id = name → role → contact → work

Build it as a funnel with one step per step_id value, not a breakdown on step_index.

Build it as two funnels, split by path, or the numbers lie: help seekers never reach work by design, so a single name → role → contact → work funnel scores every one of them as a drop-off at the last step.

  • Help seekers: name → role → contact, filtered to persona = legal_seeker
  • Professionals: name → role → contact → work, filtered to persona = legal_pro

Alternatively filter the work step to professionals only. Add a second insight trending onboarding.step_back by step_id — a step people reverse out of is a step that asked something unclear.