A/B testing onboarding with PostHog
Run onboarding experiments from your app, serve whole different flows, measure downstream results, and use PostHog as the analytics hub. FormFlow sends events from its own server, so ad-blockers do not matter.
Overview
An experiment splits visitors of one entry point across versions. A version is either a patch (copy, layout, theme, landing blocks) or a different form of the same workspace, so questions, order and logic can differ. Every response records the experiment, the version and the form version it saw. Goals go beyond completion: answers, time, and conversions your own backend or PostHog reports later.
Setup
1. Connect PostHog
In Integrations, add an analytics destination of kind posthog. It is workspace-level and stored encrypted like other credentials.
| Field | Notes |
|---|---|
| Project API key | Required. phc_.... Used to send events and to evaluate flags. |
| Host | Blank is the US cloud (https://us.i.posthog.com). Use https://eu.i.posthog.com for EU, or your own URL. Self-hosted hosts go through the SSRF-protected client, so private-network installs are refused. |
| Personal API key and project id | Optional, together. Needed for cohort and person-property targeting and for pulling conversions with HogQL. Never sent to the browser. |
Use Send test event on the destination to deliver one formflow_test_event. Every delivery, with its attempts and error, is in the delivery log. Failed batches retry with backoff through the job queue (up to 6 attempts); permanent errors are not retried.
2. Turn on forwarding per form
Forwarding is off until a form enables it in its analytics settings, or the experiment sets config.forward to true. Choose which destinations receive it, which answers are shareable, the answer-to-person-property mapping and an optional group key. An experiment can also switch forwarding off for itself with forward: false.
uid, distinct_id and the URL parameters
uid is your app's stable user id. It is the key for stable assignment and, by default, the PostHog distinct_id. If your PostHog SDK uses a different distinct id, pass it as ph_distinct and events use that instead. Without a uid, an anonymous id (cookie ff_aid) is used.
| Parameter | Meaning |
|---|---|
uid | The app's stable user id. Keeps a user in one version across devices and reinstalls. |
uid_sig | Lowercase hex HMAC-SHA256 of the uid with the workspace identity secret. See signed identity. |
aid | Anonymous id. The hosted page sets it from its cookie; native apps may pass their own. |
attrs.<key> | An attribute for targeting and segments, for example attrs.plan=pro. Also read: platform, app_version, country, plan, source, lang, utm_*. |
ph_session | PostHog session id ($session_id also works). Sent as $session_id so events attach to session replay. |
ph_distinct | The PostHog distinct id to use for events and flag evaluation. |
variant | Forces a version id for QA or external assignment. A forced visit is never counted, recorded, or tagged on responses. |
native=1 | Marks a native WebView (layout and bridge). Holdout applies everywhere: a held-out visitor never gets the form, on the hosted page, in embeds, in apps and over the API. |
Signed identity
A uid in a URL can be forged. Create the workspace identity secret in the workspace identity settings (the app calls PUT /api/internal/identity with rotate: true); it is shown once. Your backend computes uid_sig and hands it to the app. With require signed uids on, an unsigned or invalid uid is treated as anonymous: no PostHog person reads, no prefill from person data, no stable assignment by uid. Verification is constant time. With it off, an unsigned uid is accepted but not trusted for person data.
import { createHmac } from "node:crypto";
const uidSig = createHmac("sha256", process.env.FORMFLOW_IDENTITY_SECRET!).update(uid).digest("hex");import CryptoKit
func uidSig(_ uid: String, secret: String) -> String {
let mac = HMAC<SHA256>.authenticationCode(for: Data(uid.utf8), using: SymmetricKey(data: Data(secret.utf8)))
return mac.map { String(format: "%02x", $0) }.joined() // compute on your server, not in the app
}import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
fun uidSig(uid: String, secret: String): String {
val mac = Mac.getInstance("HmacSHA256").apply { init(SecretKeySpec(secret.toByteArray(), "HmacSHA256")) }
return mac.doFinal(uid.toByteArray()).joinToString("") { "%02x".format(it) } // compute on your server
}Never ship the secret in the app. The Swift and Kotlin snippets show the algorithm; run it where the secret lives.
Experiment kinds
| Kind | What varies | Analysis |
|---|---|---|
| A/B/n (<C>ab</C>) | Up to 8 versions. Each is a patch or a different form. | Primary metric per version against the original. |
| Multivariate (<C>multivariate</C>) | Independent factors (for example welcome copy x question order x design), 2 to 4 levels each, up to 16 cells. Each user is drawn per factor, so cells follow the level shares. Factor levels are patches. | Cells plus a main effect per factor level against the factor's first level. |
| Question (<C>question</C>) | One question: its label, description, placeholder, option labels and order (ids stay), or its position. | Answer distribution of that question per version, plus its drop-off. |
Version types
A version with a formId shows a different form. It must be in the same workspace and published, and cannot be the original. The original (control) is always the experiment's own form and is never patched. Visitors see the variant form's published version. If you publish the variant form while the experiment runs, the new version is simply recorded: results list views and completions per variant per form version, so you can see which version each response saw.
An experiment may own an entry slug. Visitors open /f/<entrySlug>, are assigned there, and see the variant form; responses are stored under the variant form, tagged with experimentId and variantId. Without an entry slug the test runs on the original form's address.
Assignment
Order of decisions: forced variant, then the stored assignment, then a finished test (winner or per-segment rules), then eligibility, holdout, the PostHog flag if configured, and finally FormFlow's bucket.
- Deterministic bucket. SHA-256 of experiment id and uid, first 48 bits, mapped to [0, 1) and through the version weights. Same uid, same version, on any server and device. Without a uid the anonymous id is used, remembered in a first-party cookie.
- Stored assignments. At first exposure the (experiment, uid or anonymous id) to version mapping and the first exposure time are stored and never change, even when weights move. A user whose version is later paused by a guardrail is moved once.
- Exposure. The hosted page records exposure when the form is viewed (bots, headless browsers and visitors who declined consent are not counted). Over the API,
deciderecords it.
PostHog as the assignment source
Set the experiment's assignment to { "source": "posthog", "flagKey": "onboarding-test", "variantMap": { "test": "b" } }. Let PostHog Experiments own the split while FormFlow serves the flows. FormFlow calls POST {host}/flags?v=2 with the project key and the uid as distinct_id, waits at most 1.5 seconds, and caches the result per (flag, uid) for 3 minutes. The flag variant is mapped through variantMap, or used directly when a FormFlow version has the same id. If the flag is off, unmapped, slow or failing, FormFlow assigns with its own bucket. A uid is required; anonymous users always use FormFlow assignment.
For FormFlow-assigned tests, the first exposure sends $feature_flag_called with $feature_flag set to config.exposureKey (default: the experiment id) and $feature_flag_response set to the version id, so PostHog can analyse the experiment too. Create a flag with that key in PostHog.
Lucida
Add an analytics destination of kind lucida in Integrations with a project key (lc_sk_ or lc_pk_), the capture host (blank is hosted) and the project id, which is not numeric. A read key (a secret project key with *:read scope, or lc_pat) is optional and enables flag reads, cohorts, person properties and conversions. Events use the same formflow_* names and properties as PostHog, with $geoip_disable set. See the Lucida capture docs.
- Assignment. Set
{ "source": "lucida", "flagKey": "onboarding-test", "variantMap": { "test": "b" } }. The flag is evaluated at the capture host withPOST /v1/flags, with a 1.5 second timeout and a 3 minute cache, and falls back to FormFlow's bucket. - Targeting.
lucida.cohort(value: cohort id, a UUID) andlucida.person.<property>work like the PostHog rules and need a trusted uid and a read key. - Conversions. Mark a conversion metric with
source: "lucida". An hourly job counts the goal event with an LQL query onPOST /v1/projects/{id}/sql. - Exposures. The first exposure sends
$feature_flag_calledand$exposure, which Lucida experiments read. - Ids. The
ph_sessionandph_distinctparameters carry Lucida's ids too. In the embed SDK, pass them asanalyticsSessionIdandanalyticsDistinctId.
Holdout, targeting, traffic and layers
- Holdout.
holdoutpercent of enrolled users getvariantId: "holdout"and no form. The SDKreadyordecidereports it and the app skips onboarding. Measure it with downstream conversions. On the hosted page setholdoutExitin the config:redirectUrl(https only) sends the visitor on withuidand the page's params kept (the signature and anonymous id are not forwarded), ormessageis shown on a minimal "You're all set" screen with a Continue button. Embeds postholdoutthencloseto the host, andembed.jscallsonHoldoutor removes the embed. No form loads and nothing is submitted, but the view counts as the holdout arm's exposure. Visitors outside the traffic share or targeting are excluded instead: they see the original form, untracked, on every surface includingdecide. - Targeting. Rules on attributes (
platform,app_versionwithvgte/vlt,country,locale,source,plan, anything passed asattrs.*) with operatorseq neq in nin gt gte lt lte contains exists vgte vlt, matched with all or any. Rules onposthog.cohort(value: cohort id) andposthog.person.<property>use the personal API key, need a trusted uid, and are cached 5 minutes. A failed lookup counts as no match. - Traffic allocation.
trafficpercent of eligible users enter the test. The rest see the original and are not tracked. - Layers. Tests in the same layer own disjoint slices
[from, from + traffic)of one hash space, so a user is in at most one of them. Starting a test whose slice overlaps a running one in the same layer is refused.
Scheduling and ramp
schedule.startAt and schedule.stopAt (epoch ms) start and stop the test; ramp steps (for example 10, 50, 100 percent) advance on a date (at) or once the test has whenViews views. A job runs every five minutes, so timing is accurate to about that. Split-ratio checks are skipped while ramping or running a bandit.
Events sent to destinations
One event schema, several destinations. All names start with formflow_.
| Event | When | Extra properties |
|---|---|---|
formflow_form_viewed | First view of a visit | none |
formflow_form_started | First interaction | none |
formflow_question_viewed | A question is shown | field_id, index, type |
formflow_question_answered | A question is answered | field_id, index, type, time_on_question_ms, value only for fields marked shareable |
formflow_form_completed | Server-side, when the response is stored | duration_ms, ending_id, outcome, score; person properties as $set / $set_once |
formflow_form_abandoned | A started visit idle past the form's threshold (default 30 min, checked every 5 min) | last_field_id |
$feature_flag_called | First exposure to an experiment | $feature_flag, $feature_flag_response, $feature/<key> |
Properties on every event
form_id, form_version, experiment_id, variant_id, locale, device, utm_*, and $feature/<flagKey> (the version id) when assignment comes from PostHog. The distinct_id is ph_distinct, else the uid, else the anonymous id (anonymous events carry $process_person_profile: false). $session_id is set from ph_session. $groups is set when the form maps a group type and an attribute holding the company id.
What is never sent
No answer value leaves unless the field is listed as shareable, and never for sensitive, secret, signature, file or payment fields. Person-property mapping ("answer of field X becomes person property Y", set or set_once) skips the same fields. Under Protected data mode nothing is sent at all.
Other destinations
| Destination | Delivery | Mapping |
|---|---|---|
| PostHog | POST /batch/; person properties as $set, $set_once inside properties; $groups | Native. |
| Lucida | Capture host with the project key; same events and person properties as PostHog | Optional read key; blank host is hosted. |
| Amplitude | HTTP V2 API. user_id, or device_id for anonymous; user_properties.$set | US or EU region. |
| Mixpanel | /track with the project token, and /engage for profile updates | US or EU region. |
| Segment | /v1/batch: a track per event and an identify when person properties arrive | Write key; optional host. |
Each destination has an event filter (only these event names) and a property rename map, applied before the batch is queued.
Goals and metrics
A test has one primary metric and up to five secondary metrics.
| Metric | Definition | Better |
|---|---|---|
completion | Completed responses over views. | Higher |
time | Mean seconds to complete (from the response's recorded duration). Reported as means and relative difference, no significance test. | Lower |
answer | Share of views that completed and answered fieldId with op value, for example plan = pro, team size >= 10. Unavailable under Protected data mode (answers are sealed). | Higher |
conversion | Share of exposed users with an external event within an optional windowHours of first exposure. Holdout users are included. | Higher |
Per-question drop-off is always shown: for each version, the number and share of visits that reached each question of that version's form. Time per question is the gap between the question being shown and answered, measured from beacon arrival, so it is accurate to a few seconds.
Reporting a downstream conversion
From your backend, with an API key that has the form:write scope (the key never goes into the app). The user must have been exposed; unknown users and events before exposure are acknowledged but not counted. A repeat of the same event counts once unless you send a different eventId.
curl -X POST https://forms.example.com/api/v1/experiments/exp_123/conversions \
-H "Authorization: Bearer $FORMFLOW_API_KEY" -H "Content-Type: application/json" \
-d '{ "uid": "user_42", "event": "subscribed", "value": 9.99 }'await formflow.request("POST", "/experiments/exp_123/conversions", { body: { uid: "user_42", event: "subscribed", value: 9.99 } });Or let PostHog report it: mark a conversion metric with source: "posthog". An hourly job counts the goal event per distinct_id after exposure with a HogQL query (needs the personal API key and project id) and stores the first occurrence. Only users identified by uid are pulled.
Statistics
- Frequentist. Two-proportion z-test against the original with a Wilson 95% interval and a Bonferroni correction across versions.
- Bayesian. Beta-Binomial with a uniform prior, 20,000 seeded Monte Carlo draws: probability to beat control, probability of being best, expected loss (rate you give up if you choose the version and the original was better) and expected lift. The same counts always give the same numbers.
- Sample ratio mismatch. Chi-square on assigned users against the configured split. p < 0.001 is flagged: the split is broken, so do not trust the results.
- Guardrails. For example "completion must not drop more than 10% relative". A breach needs the sample minimum, a drop beyond the limit, and 95% probability the version is truly worse. The version is paused (its traffic goes to the original) and
experiment.guardrail_breachedis sent. - Sample-size guard. Results warn while the smallest version has under 100 users; a verdict needs at least 100 views per version and 20 completions.
- Over time. Daily cumulative rate, lift and a 95% interval on the difference. A novelty hint appears when the second half of the test is well below the first.
- Segments. By any attribute captured at exposure. Each cut is a separate comparison, so the screen shows a stricter threshold (0.05 divided by the number of comparisons). Treat segment leads as hypotheses.
Auto-optimise
- Bandit. With
bandit.enabled, a daily job runs Thompson sampling on the primary metric (not available for time) and sets version weights from the share of draws each version wins, keeping at leastfloorpercent on every live version. It waits until every live version hasminViewsusers. SetcontextualAttr(for exampleplatform) to learn weights per attribute value. SRM is not checked for bandit tests. - Auto-stop. With
autoStop.enabled, when one version's probability of being best reachesprobabilityand every live version hasminViewsPerArm, the winner is marked andexperiment.winner_declaredfires. WithautoPromotethe test completes and everyone gets the winner (experiment.promoted); otherwise it stops and waits for you. - Per-segment winners. Personalize keeps the clearly winning version per segment (probability best and of beating control above 90%, at least 30 users per version) as permanent targeting rules and completes the test. Everyone else gets the overall winner, or the original.
Webhooks
Subscribe a webhook endpoint to experiment.started, experiment.paused (a stop, manual or scheduled), experiment.guardrail_breached, experiment.winner_declared and experiment.promoted. Payloads carry experimentId, formId and name, plus the version, metric or evidence involved.
AI help
Suggest variants proposes two or three ideas with a hypothesis each: a ready patch, a single-question rewrite, or a draft copy of the form with reordered or reworded questions (created as a draft, which you publish and add as a version). Explain results writes a plain-language summary with the funnel drop-offs and a recommendation. The recommendation is computed from the statistics and the model only writes the wording, so it cannot call a winner the numbers do not support. Only labels, counts and statistics are sent, never answers. Both are off under Protected data mode, and fall back to rule-based output when no AI provider is configured.
End to end: a mobile onboarding test
Goal: compare the current onboarding (control) with a shorter flow (another form) and a holdout, and optimise for paid conversion.
- Publish
onboardingandonboarding-short. Connect PostHog and enable forwarding on both. - Create the experiment on
onboardingwith entry slugfirst-launch, versionscontrol(45%) andshort(45%, formonboarding-short), holdout 10%, primary metricconversiononsubscribedwithin 168 hours, secondarycompletion, guardrail on completion (max drop 15%). Start it. - Your backend signs the uid and returns
uid_sigto the app. The app passesuid,uid_sig, attributes and the PostHog ids.
let client = FormFlowClient(baseURL: URL(string: "https://forms.example.com")!)
let req = FormFlowDecideRequest(entry: "first-launch", uid: user.id, uidSignature: user.formflowSig,
attributes: ["plan": user.plan, "platform": "ios"],
posthogSessionId: PostHogSDK.shared.getSessionId())
Task {
guard let d = try? await client.decide(req) else { return finishOnboarding() } // fail open
if !d.show { return finishOnboarding() } // holdout: variantId == "holdout"
let base = FormFlowConfig(baseURL: client.baseURL, slug: "", uid: user.id, uidSignature: user.formflowSig,
attributes: ["plan": user.plan, "platform": "ios"],
posthogSessionId: PostHogSDK.shared.getSessionId(), posthogDistinctId: PostHogSDK.shared.getDistinctId())
guard let config = d.config(from: base) else { return finishOnboarding() }
// present FormFlowView(config:); the ready event carries experimentId and variantId
}val client = FormFlowClient(context, "https://forms.example.com")
val req = DecideRequest("first-launch", uid = user.id, uidSignature = user.formflowSig,
attributes = mapOf("plan" to user.plan, "platform" to "android"),
posthogSessionId = PostHog.getSessionId()?.toString(), posthogDistinctId = PostHog.distinctId())
val base = FormFlowConfig("https://forms.example.com", "", uid = user.id, uidSignature = user.formflowSig,
attributes = req.attributes, posthogSessionId = req.posthogSessionId, posthogDistinctId = req.posthogDistinctId)
val config = runCatching { client.decide(req) }.getOrNull()?.config(base) // null = holdout or offline
if (config == null) finishOnboarding() else FormFlowForm(config, onComplete = { finishOnboarding() }, onTimeout = ::finishOnboarding)Without the SDK's decide call, open /embed/<slug>?native=1&uid=...&uid_sig=...&attrs.plan=pro&ph_session=... in a WebView. The ready event includes experimentId and variantId; log them in your own analytics if you like. The embed page is served by the mobile SDK work; check that your version of the embed page applies experiments before relying on it.
- When the user pays, your backend reports it (see the conversion call above). Every holdout, control and short-flow user can convert, so the lift of onboarding itself is measurable.
- In PostHog, build a funnel on
formflow_form_viewed,formflow_form_started,formflow_form_completed, then yoursubscribedevent, broken down byvariant_id. Useformflow_form_abandonedwithlast_field_idto find where people leave. - Open the results in FormFlow: Bayesian and frequentist numbers, per-question funnel, the split check, then use Explain results. Promote the winner or personalize per segment.
Limitations
- The HogQL queries for cohort membership, person properties and conversion counts are written from PostHog's documented query API and have not been verified against a live PostHog project. Test with your project before depending on cohort targeting or the conversion pull.
- Time per question comes from when the browser beacon reached the server (it flushes every 5 seconds), so it is accurate to a few seconds.
- Under Protected data mode answers are sealed: answer goals, question distributions, person properties, forwarding and AI are unavailable.
- Segment and results queries read up to 200,000 rows per test.
- A view that arrives before the visitor has decided on a consent banner is recorded anonymously but not forwarded.