Skip to content

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

PlatformPackageInstall
React Native@formizeflow/react-nativenpm i @formizeflow/react-native react-native-webview
iOS (15+)FormFlowSwift Package Manager: github.com/formizeflow/formflow-ios
Android (minSdk 23)io.github.formizeflow:formflow-androidGitHub release AAR: github.com/formizeflow/formflow-android
Flutterformizeflowflutter pub add formizeflow (or a git dependency on github.com/formizeflow/formflow-flutter)

Show a form

tsx
// 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} />
swift
// SwiftUI
FormFlowView(config: FormFlowConfig(baseURL: URL(string: "https://YOUR_HOST")!, slug: "app-onboarding", uid: user.id),
             onComplete: { _, _, _, _, _ in finish() }, onClose: finish, onTimeout: finish)
kotlin
// Jetpack Compose
FormFlowForm(FormFlowConfig("https://YOUR_HOST", "app-onboarding", uid = user.id),
    onComplete = { finish() }, onClose = { finish() }, onTimeout = { finish() })
dart
// Flutter
FormFlowForm(config: FormFlowConfig(baseUrl: 'https://YOUR_HOST', slug: 'app-onboarding', uid: user.id),
  onComplete: (c) => finish(), onClose: finish, onTimeout: finish)

Events

EventData
readyformId, versionId, experimentId?, variantId?, locale
viewthe form was shown
startfirst interaction
stepindex, fieldId, total (fieldId is the most recently answered field)
answerfieldId, type; value only with answers enabled and a non-sensitive field
completesubmissionId, outcome, endingId, durationMs, answers? (non-sensitive, with answers enabled)
requestrequestId, action, params: the form asks the app to do something
closethe user tapped the close button (showClose)
error / timeoutload 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.

ts
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();
The request is 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.

js
// Node
import { createHmac } from "node:crypto";
const sig = createHmac("sha256", process.env.FORMFLOW_SIGNING_SECRET).update(userId).digest("hex");
python
import hmac, hashlib, os
sig = hmac.new(os.environ["FORMFLOW_SIGNING_SECRET"].encode(), user_id.encode(), hashlib.sha256).hexdigest()
go
mac := hmac.New(sha256.New, []byte(os.Getenv("FORMFLOW_SIGNING_SECRET")))
mac.Write([]byte(userID))
sig := hex.EncodeToString(mac.Sum(nil))
text
# 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 endpoint

PostHog 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:

text
[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.

swift
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.

tsx
// 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} />;
swift
// 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)
kotlin
// 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() })
dart
// 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);