Mobile apps
Show FormFlow forms inside your iOS, Android, React Native and Flutter apps. Built for onboarding flows, with experiments, holdouts and server-side resume.
Each SDK wraps the hosted form in a WebView and talks to it through a native bridge (protocol v3 of the embed page). Nothing secret lives in the app: the form is loaded from your FormFlow host, answers are submitted by the hosted page, and you read them through the API or webhooks.
Install
| Platform | Package | Install |
|---|---|---|
| React Native | @formizeflow/react-native | npm i @formizeflow/react-native react-native-webview |
| iOS (15+) | FormFlow | Swift Package Manager: github.com/formizeflow/formflow-ios |
| Android (minSdk 23) | io.github.formizeflow:formflow-android | GitHub release AAR: github.com/formizeflow/formflow-android |
| Flutter | formizeflow | flutter pub add formizeflow (or a git dependency on github.com/formizeflow/formflow-flutter) |
Show a form
// React Native
<FormFlowForm baseUrl="https://YOUR_HOST" slug="app-onboarding" uid={user.id}
hidden={{ app_version: "3.2.0" }} onComplete={(c) => finish(c.submissionId)} onClose={finish} onTimeout={finish} />// SwiftUI
FormFlowView(config: FormFlowConfig(baseURL: URL(string: "https://YOUR_HOST")!, slug: "app-onboarding", uid: user.id),
onComplete: { _, _, _, _, _ in finish() }, onClose: finish, onTimeout: finish)// Jetpack Compose
FormFlowForm(FormFlowConfig("https://YOUR_HOST", "app-onboarding", uid = user.id),
onComplete = { finish() }, onClose = { finish() }, onTimeout = { finish() })// Flutter
FormFlowForm(config: FormFlowConfig(baseUrl: 'https://YOUR_HOST', slug: 'app-onboarding', uid: user.id),
onComplete: (c) => finish(), onClose: finish, onTimeout: finish)Events
| Event | Data |
|---|---|
ready | formId, versionId, experimentId?, variantId?, locale |
view | the form was shown |
start | first interaction |
step | index, fieldId, total (fieldId is the most recently answered field) |
answer | fieldId, type; value only with answers enabled and a non-sensitive field |
complete | submissionId, outcome, endingId, durationMs, answers? (non-sensitive, with answers enabled) |
request | requestId, action, params: the form asks the app to do something |
close | the user tapped the close button (showClose) |
error / timeout | load failure, or not ready within the timeout (default 8 s) |
URL and options
Every SDK loads BASE/embed/SLUG?native=1 and adds uid, uid_sig, variant, lang, answers=1 (opt-in), close=1, resume=0, ph_session, ph_distinct, scheme, accent, font, hidden.*, prefill.* and attrs.*, all URL-encoded. Platform, OS version, locale, device model and app version are filled into attrs.* automatically (turn off with autoAttributes). Navigation is limited to your host; other links open in the system browser. Each SDK adds a FormFlow<Platform>/version user agent suffix.
Decide first: holdouts, variants, skip
Call decide before the onboarding step. It returns { show, experimentId, variantId, formId, slug, versionId, reason, definition? }. When show is false (holdout, already completed, no match) skip the flow; otherwise show slug with variant. If the network fails, the SDK returns the last cached decision, and if there is none, the call fails and your app continues without the flow. preload warms that cache at app start; on iOS FormFlowViewController.preload() and on Android FormFlowView.preload() also load the page so it appears instantly.
const client = new FormFlowClient({ baseUrl, storage: AsyncStorage });
client.preload({ entry: "first-launch", uid, uidSignature }); // app start
const d = await client.decide({ entry: "first-launch", uid, uidSignature }).catch(() => null);
const props = d && decisionToProps(d); // null: holdout, done, or offline
if (!props) return next();POST /api/v1/public/onboarding/decide with { entry, uid, uid_sig, attributes, ph_session, ph_distinct }. You can render natively from definition if you prefer; the SDKs do not.Signed uid
A uid alone can be forged by anyone who knows another user's id. Sign it on your server and hand the signature to the app as uidSignature. The signature is the hex HMAC-SHA256 of the uid with your signing secret (Settings, Developers). Never ship the secret in the app.
// Node
import { createHmac } from "node:crypto";
const sig = createHmac("sha256", process.env.FORMFLOW_SIGNING_SECRET).update(userId).digest("hex");import hmac, hashlib, os
sig = hmac.new(os.environ["FORMFLOW_SIGNING_SECRET"].encode(), user_id.encode(), hashlib.sha256).hexdigest()mac := hmac.New(sha256.New, []byte(os.Getenv("FORMFLOW_SIGNING_SECRET")))
mac.Write([]byte(userID))
sig := hex.EncodeToString(mac.Sum(nil))# Any server language (Swift on Server, Kotlin, Ruby, PHP ...)
key = bytes(SIGNING_SECRET)
sig = lowercase_hex( HMAC_SHA256(key, utf8(uid)) )
# return { uid, sig } from your authenticated session endpointPostHog session linking
Pass the PostHog session and distinct id so form events join the same session replay and funnels. React Native: posthog={usePostHog()}. iOS, Android and Flutter: pass posthogSessionId and posthogDistinctId from the PostHog SDK. On the web, FormFlow.open(slug, { posthog: true }) reads window.posthog. This is opt-in; nothing is sent unless you pass it.
Native actions from the form
A form can ask the app to act, for example to request notification permission, ATT tracking, open a URL, sign in or follow a deep link. In the form text add a link whose address ends in #ff-action=NAME, with optional extra parameters:
[Enable notifications](https://example.com/#ff-action=notifications&topic=news)Tapping it emits a request event { requestId, action: "notifications", params: { topic: "news" } }. Handle it with onRequest (the return value is sent back to the form as the result; the link gets a data-ff-result attribute) or call respond yourself. Feeding the result into form logic as a variable is not available yet.
h.onRequest = { r, respond in
guard r.action == "notifications" else { return respond(.string("unsupported")) }
UNUserNotificationCenter.current().requestAuthorization(options: [.alert]) { ok, _ in respond(.string(ok ? "granted" : "denied")) }
}Resume
With a uid, progress is saved on the server and the form resumes across sessions and devices. Pass resume: false to always start fresh, or call restart() (React Native ref, FormFlowViewController.restart(), FormFlowView.restart(), FormFlowController.restart()) from a "start over" button.
Reading answers
Three ways, in order of preference: the submission.completed webhook (join on uid), the REST API (GET /api/v1/forms/:id/submissions), or, with answers enabled, the complete event and the answer stream in the app. Sensitive fields are never sent to the app.
Appearance
appearance (React Native, Android, Flutter) or FormFlowAppearance (iOS) sets colorScheme (light, dark, system), accent (#rrggbb) and fontFamily at runtime. The hosted page honours them over the form theme.
First-launch onboarding experiment
The same shape on every platform: preload at launch, decide at the onboarding step, skip on holdout or failure, show the variant, react to steps, answer native requests, save on complete, and always continue the app on timeout.
// React Native
const client = new FormFlowClient({ baseUrl, storage: AsyncStorage });
useEffect(() => { client.preload({ entry: "first-launch", uid: user.id, uidSignature: user.sig }); }, []);
const d = await client.decide({ entry: "first-launch", uid: user.id, uidSignature: user.sig, attributes: { plan: user.plan } }).catch(() => null);
const p = d && decisionToProps(d);
if (!p) return next();
return <FormFlowForm baseUrl={baseUrl} {...p} uid={user.id} uidSignature={user.sig} posthog={posthog} includeAnswers
onStep={(s) => s.fieldId === "notifications_q" && prep()} onRequest={(r) => handle(r)}
onComplete={(c) => { save(c.answers); next(); }} onTimeout={next} onClose={next} />;// iOS
let client = FormFlowClient(baseURL: url)
let req = FormFlowDecideRequest(entry: "first-launch", uid: user.id, uidSignature: user.sig)
guard let d = try? await client.decide(req), let cfg = d.config(from: base) else { return finish() }
let vc = FormFlowViewController(config: cfg)
vc.handlers.onComplete = { _, _, _, _, answers in save(answers); finish() }
vc.handlers.onTimeout = finish
present(vc, animated: true)// Android
val client = FormFlowClient(context, "https://YOUR_HOST")
val cfg = runCatching { client.decide(DecideRequest("first-launch", user.id, user.sig)) }.getOrNull()?.config(base)
if (cfg == null) finish() else FormFlowForm(cfg, onComplete = { save(it.answers); finish() }, onTimeout = { finish() })// Flutter
final client = FormFlowClient(baseUrl: baseUrl);
FormFlowConfig? cfg;
try { cfg = (await client.decide(entry: 'first-launch', uid: user.id, uidSignature: user.sig)).config(base); } catch (_) {}
if (cfg == null) return finish();
return FormFlowForm(config: cfg, onComplete: (c) { save(c.answers); finish(); }, onTimeout: finish);