Skip to main content
Supported on: Android SDK 1.0.33+
You can A/B test this onboarding flow with no extra code — create an experiment on it in the Console and the SDK serves the assigned variant automatically. See Servable Surface Experiments.
The AppDNA onboarding module lets you present server-driven onboarding flows that are configured in the AppDNA Console. Flows are delivered to the SDK via the remote config bundle, so you can update onboarding experiences without shipping app updates.

Present an Onboarding Flow

Present a specific onboarding flow by ID:
The method returns false if the flow configuration is not available (e.g., config has not loaded yet or the flow ID is invalid).
If flowId is null, the SDK presents the currently active flow as configured in remote config. This is useful when you want the Console to control which flow is shown.

Module Access

Access the onboarding module directly:

Module Methods

The context argument is accepted for API symmetry with iOS but is not yet propagated to the underlying flow on Android — pass context through userProperties on identify(...) if you need it available in the running flow.

OnboardingContext

Pass additional context when presenting a flow:

Paywalls inside onboarding flows

Steps with outcome: present_paywall open a paywall and route the user based on the result (purchase / fail / dismiss → next step). The host’s AppDNAPaywallDelegate (registered via AppDNA.paywall.setDelegate(...)) fires for these embedded paywalls identically to standalone presentations — purchase, restore, action, promo-code, and post-purchase callbacks all reach the host. See the paywall docs for the full delegate surface.

AppDNAOnboardingDelegate

Implement the delegate interface to respond to onboarding flow events:

Example Implementation

Backend-interactive elements

Hook: onElementInteraction.
Available on Android SDK 1.0.39+ / iOS SDK 1.0.67+.
Some content blocks are interactive — the user can act on them while the step is on screen (tap a calendar day, enter an OTP digit, flip a memory card, toggle dark mode, connect a health source). When that happens, the SDK calls onElementInteraction so your app can talk to your backend and push the result back into the live step — without advancing or re-presenting it. The action + value arguments identify what the user did.
Each interactive element sends a fixed action (identical on Android and iOS) with an optional value: Return an ElementInteractionResult to update the rendered step, or null to do nothing (the default):
Example — confirm a health-data connection with your backend, then flip the card to its connected state in place:
fieldConfigPatches are layered as read-time overrides on top of the (immutable) block, so the element re-renders with the new state while Android and iOS stay at parity. inputValuePatches are merged into the values the step reports on completion. The hook ships with a default no-op implementation, so it is fully opt-in.

Permissions

Add a permission step in the Console and choose a permission type — Notifications, App Tracking, Location, Camera, Microphone, Photos, Contacts, or Calendar. When the user taps the step’s primary button, the SDK requests the real OS permission, records the outcome, and advances.

Required manifest permissions (your app)

Declare the permissions you use in your app’s AndroidManifest.xml. If a permission isn’t declared, the SDK reports it as unavailable and advances (no crash). The SDK’s own manifest merges only POST_NOTIFICATIONS (plus INTERNET, ACCESS_NETWORK_STATE, WAKE_LOCK and VIBRATE); it adds no other runtime permission. Declare a row above only when your flow authors that permission step.

Routing on grant vs deny

After the prompt, the SDK stores permission_<type> = "granted" or "denied" in the step’s collected values, so you can branch with next-step rules (e.g. skip a reminders screen when permission_notification is denied).

Delegate

If a permission was previously denied (the OS will not prompt again), enable Offer “Open Settings” if already denied on the step to show a shortcut into your app’s settings.

Step Types

Onboarding flows support the following step types, configured in the Console:
Step types and their content are defined entirely in the Console. The SDK renders them automatically based on the flow configuration. You do not need to build UI for individual step types.

Selection Modes

Question steps support two selection modes:

Content Blocks

Each onboarding step is composed of content blocks that control the visual layout. Blocks are configured in the Console and rendered natively using Jetpack Compose. The block string the Console emits is the same string the SDK switches on. The following block types are available:

Layout primitives

Interactive blocks

Form input blocks

Form inputs are individual blocks (not a single form block). Each input type emits its current value through the step’s inputValues map and is included in the step responses on completion.
Blocks are configured entirely in the Console. The SDK renders them automatically — no code is needed unless you register custom views.

Advanced content blocks

Available on Android SDK 1.0.39+ / iOS SDK 1.0.67+.
These richer blocks are configured in the Console like any other block. The element-specific settings below live on the block’s field_config object (the table notes the key fields); shared styling (text, bg_color, text_color, active_color, field_id) works as it does on other blocks. Blocks marked interactive fire onElementInteraction when the user acts on them.

Button Actions

button and social_login blocks emit a structured payload to your AppDNAOnboardingDelegate.onBeforeStepAdvance hook when tapped. The action value on the button (configured in the Console) determines what shape the payload takes and lets you route to the right backend call (sign in, register, request OTP, etc.). If the user has not completed required form fields on the step, the SDK shows an inline validation toast and does not emit the action — same gate as the standard next button.

Flow control

Authentication entry

When the user taps a button with one of these actions, the SDK calls your onBeforeStepAdvance handler with the payload below. Show a “Signing in…” spinner by returning StepAdvanceResult.Block(message) while async auth runs, then return Proceed / ProceedWithData when the backend responds.
If you do not implement AppDNAOnboardingDelegate, the SDK stays on the step and logs a warning rather than advancing past credential collection. Auth actions require a host to perform the actual side effect (sign in, register, send OTP, …) — silently advancing would leak credentials into the response bundle without ever calling your backend.

Account lifecycle

request_otp channel resolution

request_otp and verify_otp include a channel field so your handler knows which delivery method to call back on. The SDK resolves it in this order:
  1. Explicit — set the button’s action_value to "sms", "email", "whatsapp", or "voice" in the Console (case-insensitive).
  2. Auto-detect — if the step has exactly one phone-typed input (input_phone) → channel: "sms"; exactly one email-typed input (input_email) → channel: "email".
  3. Omitted — if the step has both an email AND a phone input (or neither), the channel key is absent from the payload. Check with stepData?["channel"] as? String and fail explicitly if null rather than guessing. Set action_value on the button to disambiguate.
The matching input field’s value is included as recipient so you don’t have to look it up from the step responses.

Handling auth actions

Existing next, skip, link, permission, and social_login button actions are unchanged. Host apps that already implement AppDNAOnboardingDelegate keep working without changes — the default onBeforeStepAdvance returns Proceed, so flows that don’t switch on stepData?["action"] advance normally. Host apps that have NO delegate set will see the no-delegate warning above when an auth action fires; for those flows, either implement the delegate or keep using next as the button action.
A flow with a sign-in step needs onBeforeStepAdvance. The no-delegate block above applies only when no onboarding delegate is set. If your app sets an AppDNAOnboardingDelegate that does not override onBeforeStepAdvance, the default answer is StepAdvanceResult.Proceed: a tap on a sign-in action advances the user past the step without anyone signing them in. Override onBeforeStepAdvance and answer every sign-in action (see Handling auth actions) — StepAdvanceResult.Block(message) for one you do not handle.

Sign-in timing

The native Android SDK waits for your onBeforeStepAdvance without a timeout. The Flutter and React Native bridges bound every host hook with vetoTimeout (default 5 s), but for a sign-in action they wait at least 120 s (StepAdvanceResult.minimumBridgeTimeoutMs(stepData)) — and for a refresh interaction max(vetoTimeout, 8 s), while the SDK itself ends a refresh at 8 s — so a sign-in sheet, account picker or 2FA prompt is not cut short. If no answer arrives in time, a sign-in action blocks with “Sign-in isn’t available right now. Please try again later.” and the late answer is dropped.
  • Make the sign-in handler idempotent: if a sign-in completes after the step blocked, answer Proceed immediately when the user retries.
  • Answer promptly when the user cancels the OS sheet — return Stay (or Block with a message); 120 s is a ceiling, not a wait.

Custom View Registration

Register your own Composables to be rendered inside onboarding steps wherever a custom_view block appears. The factory receives the block’s config map so you can parameterize the view from the Console:
The id must match the custom view identifier configured in the Console for the custom_view block.

Dynamic Bindings

You can pre-fill form fields and override copy at render time via onBeforeStepRender by returning a StepConfigOverride:
Available fields on StepConfigOverride: fieldDefaults, title, subtitle, ctaText, fieldOptions, dataContext (see Host-supplied data), mapRoutes. The SDK merges these into the step config before rendering. fieldOptions is Map<String, List<InputOption>> keyed by block id — supply a Select’s options from your own backend, with your own auth and cache. A block you do not name keeps its authored options.
layoutOverrides was removed. It was accepted by every SDK and read by none, so setting it never did anything; fieldOptions gives the real use case a typed home. Nothing can break, because nothing ever worked.
The SDK waits up to 3 seconds for this callback and then renders without the override, falling back to cached and then authored options. Do your own network work with your own timeout — a reply arriving after the user has left the step is discarded.

Block Styling

Every content block supports a block_style design token that controls appearance properties such as padding, margin, background color, corner radius, border, shadow, and opacity. Block styles are configured in the Console and applied automatically by the SDK.

Visibility Conditions

Blocks can be shown or hidden based on user responses, bindings, or device attributes. Visibility conditions are configured per-block in the Console using rules like answer_equals, binding_not_empty, platform_is, and locale_matches. The SDK evaluates conditions client-side before rendering each block.

Entrance Animations

Each block supports an entrance animation that plays when the block first appears. Animations are configured per-block in the Console. Supported animation types include fade, slide_up, slide_down, slide_left, slide_right, scale, flip, and bounce. You can configure duration, delay, and easing curve.

Form Steps

The form step type provides native input fields for collecting structured user data. Each form can contain multiple fields with validation, conditional visibility, and custom configuration.

Supported Input Types

Field Validation

Form fields support built-in and custom validation:
  • Required fields — marked in the Console, the SDK prevents advancing until filled
  • Regex patterns — custom validation (e.g., ^[A-Z]{2}\d{4}$ for a code format)
  • Min/max values — for number, slider, and stepper fields
  • Max length — for text and textarea fields

Conditional Fields

Fields can depend on other fields using depends_on rules. For example, a “Company name” field can appear only when the user selects “Employed” in a previous field. Supported operators: equals, not_equals, contains, not_empty, empty, gt, lt, is_set.

Form Responses

Form field values are included in the responses map passed to onOnboardingCompleted, keyed by step ID. Each step’s value is a map of field ID to field value.

Flag CTAs

A button can record the user’s choice without leaving the flow. Set its action to Flag & continue in the Console and give it a flag key (and optionally a value, default true). Tapping it advances the step exactly like a normal Continue button — same required-field validation, same next-step rules — and writes one key. Use it when a step offers something your app must act on later: “add a private workshop”, “start the trial”, “talk to a human”. Routing to that destination mid-onboarding abandons a flow the user is halfway through; the flag lets you route once, after onboarding finishes. The flag arrives in two places. It is in the step’s own answers, alongside every other value that step collected, and it is also collected flat under flags so you don’t have to walk every step to find it:
One flag per button. A step offering three things uses three buttons, each with its own key.
The value is always a string — "true" when no value was authored — so it behaves identically across iOS, Android, Flutter and React Native.

Location Fields

The location field type provides an autocomplete search input that returns structured location data including city, state, country, coordinates, and timezone. When the user starts typing (e.g., “New York”), the SDK debounces the input (300ms), calls the AppDNA geocoding proxy, and displays a dropdown of suggestions. The user selects a result and the SDK stores the complete structured data.

Location Data Structure

A location selection contains the fields below. Every field except formatted_address is optional — a selection fills the fields that have a value, the same way in a form-step Location field and a Location content block (a blank value is left out; timezone_offset is set only with a timezone), and if the user types without selecting a suggestion, getLocationData returns only formatted_address and raw_query (both the typed text) with null coordinates and null city / state / country. An empty or unanswered field returns null. The stored answer keeps address as an alias of formatted_address.

Accessing Location Data

Use AppDNA.getLocationData(fieldId) to access the selected location from anywhere in your app:

Template Engine

Location data is accessible in dynamic content templates:

Configuration in Console

In the onboarding flow editor, add a location field to a form step and configure:
Location autocomplete uses a server-side proxy — no third-party SDK is added to your app binary. The geocoding provider (Mapbox by default) can be configured in Settings > Geocoding.

Interactive Chat Steps

The interactive_chat step type renders a conversational UI that forwards each user message to your webhook and renders the reply. This is how you integrate your own LLM, agent, or rule-based backend into an onboarding flow.

How it works

  1. User types a message in the chat step.
  2. SDK POSTs the conversation payload to the webhook_url configured in the Console (with any custom headers you set).
  3. Your server responds with JSON in the schema below.
  4. SDK renders the AI reply, quick-reply buttons, media, etc.
The SDK handles turn limits, typing indicators, ratings, and quick-reply routing — your webhook only needs to return the next reply.

Request payload (SDK → your webhook)

Your headers are forwarded verbatim. For bearer-token APIs, set the header value to Bearer YOUR_TOKEN (the SDK does not add the Bearer prefix automatically). The context object is opaque session state you control — whatever you returned in data on a previous turn gets echoed back here on every subsequent call. This is how you integrate threaded AI backends (OpenAI Assistants, hosted LLMs with threadId, etc.) without replaying the full history on every request. context is absent on turn 1 (your server hasn’t written anything yet) and accumulates across turns; later values overwrite earlier ones per-key (last-write-wins). See Threaded backends below for the full pattern.

Response schema (your webhook → SDK)

Only messages[].content is required — every other field is optional. The SDK reads messages[] to render chat bubbles, so if you omit it or return a different shape, the reply won’t render.
Returning a response like {"reply": "..."} without wrapping it in the messages array will silently fail — the SDK decodes unknown fields as null and shows nothing. Always wrap your reply in messages[{ "content": "..." }].

Full response schema

Unknown fields in your response are ignored, so you can also include your own server-side state at the top level (it just won’t round-trip — use data for anything you want back).

Minimal working example

Node.js (Express):

Threaded backends: round-tripping session state

If your AI service uses a thread/session handle (OpenAI Assistants API, a hosted LLM with conversation memory, or any third-party AI proxy that mints a session id), do not replay the full conversation history on every turn — latency and cost grow linearly with conversation length. Instead, return your session handle in data once; the SDK accumulates it and echoes it back in context on every subsequent turn. The wire contract in plain English:
  • You write to context by returning fields under data in any response.
  • You read from context off req.body.context on every request after the first.
  • Keys are merged across turns; later values overwrite earlier ones per-key.
  • The SDK never inspects or mutates context — it’s opaque to us. You choose the keys and the shape.
Example: integrating a threaded AI service keyed by threadId. If your backend rotates the thread (expiry, failure, migration), just return the new id in data — the SDK overwrites the cached value and uses it on the next turn automatically.
No server-side session store, no per-user cache, no history replay. The context round-trip IS your session storage.
The same pattern works for anything opaque you need per-conversation: API rate-limit bucket ids, A/B cohort labels, tool-call state, reasoning scratchpads — whatever your AI backend needs to resume, stash it in data and read it from context on the next turn.

Console configuration

In the onboarding flow editor, add a step of type Interactive Chat, then set:

Auto-tracked events

The standard onboarding step events (onboarding_step_viewed, onboarding_step_completed) also fire for chat steps — there are no separate chat_step_viewed / chat_step_completed events.

Async Step Hooks

The onboarding delegate supports two suspend functions that let you intercept step transitions for server-side validation, dynamic content loading, or custom routing logic.

onBeforeStepAdvance

Called before the SDK advances to the next step. Return a StepAdvanceResult to control what happens next:

StepAdvanceResult

Use Stay for “I handled it; keep the user here”
Use Stay when your hook handled the user’s action — for example, sent a password-reset email — and you want the user to remain on the same step. This is distinct from Block (error styling) and from Proceed (advances).

onBeforeStepRender

Called before a step is rendered. Return a StepConfigOverride to dynamically modify the step’s content:
Both hooks are suspend functions — the SDK shows a loading indicator while waiting for your response. If either hook throws an exception or times out, the SDK proceeds normally.

Map blocks

A map block draws a static map with no app code once the map provider key is set in the console (Maps); without a key it shows a labelled placeholder. The interactive (pan & zoom) map is drawn by the Google Maps SDK bundled with AppDNA — it needs the Google provider and your key in the com.google.android.geo.API_KEY manifest meta-data (Installation); without the meta-data the map is static. On the interactive map:
  • A route’s fixed route line (polyline, from the block or from your mapRoutes) is drawn when it decodes to at least 2 points; otherwise straight segments between the stops are drawn. Only Google encoded polylines at precision 5 are supported — a precision-6 polyline (polyline6) is not supported: read as precision 5 its coordinates come out ten times too large, so it is usually rejected as out of range (and the stops are used instead), but a route whose every point lies within 9° of the equator and 18° of the prime meridian decodes in range and draws in the wrong place. Always encode at precision 5.
  • With Auto-fit to the route and stops on (the default), the camera frames every route point and stop (48 dp padding), and re-frames when your host data changes the route; a single point is shown at zoom 15. With auto-fit off, the map centres on the authored centre at the authored zoom.

Row Direction and Distribution

The row content block supports configurable direction and distribution, set in the Console:

Button Gradients

Buttons support gradient backgrounds configured in the Console: Gradients override the solid background_color when set.

Select Display Styles

input_select form-input blocks (and select form fields) support these display styles: The display style is configured per select field in the Console under field_config.display_style. Question-step option lists always render in a fixed grid layout; the styles above apply specifically to input_select / select.

Image Tile Layouts

With display_style: "image_tiles", field_config.tile_image_layout controls where the label sits relative to the photo. Text over a photograph is only readable when the photo cooperates, so the other two layouts give the text a surface of its own. An option’s image_overlay_color tints the photo only — in image_strip and contained it stops at the text surface, so a dark scrim for legibility does not also mute the band color.

Dynamic option lists (Option Sets)

A Select can take its options from an Option Set — a named list managed outside the flow — instead of a list authored inline. Editing the set reaches devices without republishing the flows that use it, which is the point: a customer who adds an item should not need anyone to touch a flow. The list is never fetched in front of the user. The SDK resolves what to show synchronously, in this order:
  1. a cached copy of the set
  2. the embedded page — the first N items, published inside the flow config
  3. the options authored on the block
  4. nothing, and the step is skipped
Only a genuinely cold start reaches step 4, and only then is a loading state shown. A refresh runs after the first frame; if it fails the list already on screen stays.
field_options is still used when a set is bound — it is the embedded page. An app built before this feature renders those items rather than an empty list, so a set is safe to introduce without waiting for everyone to update.

The selected item

After a choice, {{selected.<field_id>.…}} resolves the option rather than just its value, so a later screen can say “You chose Sunrise Estate — Lakeside” without the author repeating the list: The value your integrations already receive is unchanged. The option payload is deliberately kept out of responses, so webhook bodies are byte-identical for anyone not using this.

Template Variables

Any text an author writes can reference an answer the user has already given: Variables resolve in every text field of every onboarding block — text, labels, placeholders, an option’s label and subtitle, image and media URLs, list items and the text inside a block’s settings. (An option’s stored value can come from data only on a Select whose options follow a host list — see Host-supplied data; a fixed option’s value stays fixed.) A reference that cannot resolve and has no fallback renders as empty: an option whose label cannot resolve is hidden, and a summary stat is hidden, because a raw token in a large numeral is more jarring than an absent card. Publishing warns about references nothing supplies. SDKs before iOS 1.0.82 / Android 1.0.54 resolve variables only in a block’s text, label, placeholder, badge and toggle label, an option’s label, subtitle and leading text, and a summary stat’s value and label — and show an unresolved reference there as the literal text the author typed.

Host-supplied data

Some screens show values only your backend knows — a recommendation, a live price, a booking slot. Your app supplies them by returning a dataContext from onBeforeStepRender, and whatever is in that dictionary becomes addressable in the Console as {{hook_data.…}}.
An author then writes {{hook_data.booking.unitPrice}} in any slot that resolves variables, or binds one of a block’s properties to a path in the block’s Dynamic Data section (stored as the block’s bindings, for example {"image_url": "hook_data.recommendations.0.imageUrl"}) to drive a value that is not a text slot — an image or video URL, a colour, a number. Paths walk dictionaries and index lists, so hook_data.recommendations.0.imageUrl resolves against a list. Two things worth knowing:
  • The SDK makes no network call for this. It hands you the step and takes back a dictionary; you fetch with your own client, your own auth and your own caching.
  • Both sides have to line up. A value you supply is only read if an author has referenced that path in the Console, and a path an author references renders empty if nothing supplies it. The Implementation Manual we generate for a flow lists both halves.

Images and other settings from your data

Text fields take a {{hook_data.…}} token directly. An image field — an Image block, a Select option’s image, a Map block’s photo or marker — has a From host data mode in the Console instead: the author picks a path and keeps the uploaded image as the fallback. The Console stores that as a data_templates entry beside the static value:
A current SDK uses your value when it resolves to an absolute https URL and the fallback otherwise; an app on an older SDK never reads data_templates and shows the fallback. That is what makes a flow safe to publish while installed apps are still on older versions.

Lists that follow your data

A Select can build its options from a list you return. In the Console, turn on Options from host data on the Select, choose the list path and fill in the template option — its label, subtitle, image and stored value each come from the list item ({{item.name}}, {{item.imageUrl}}, {{item.id}}), and its static text stays as the fallback older SDKs show.
  • One option is generated per list item, up to the limit the author sets (20 by default, at most 50). Fixed options the author added are listed after the generated ones.
  • The stored answer is the item’s value ({{item.id}} above), so responses records the venue the user picked — not a slot name. Two items with the same value show once.
  • An empty or missing list shows the author’s empty-state text, or hides the Select if they chose that. A required Select with nothing to pick never blocks the user.
  • Navigation rules cannot compare a generated answer to a fixed value — the values are whatever your app sends. Branch on “answered”, or on a fixed option.

While your data loads

The step appears immediately — nothing waits to paint. When you have set a delegate and the step references hook_data, a Select whose options depend on your data shows no generated options until your reply arrives, for at most 3 seconds, and cannot be submitted meanwhile (pressing Continue says “Loading…”); fixed options and everything else render normally. If your reply is later than 3 seconds, the Select settles without it (its empty state, if the author set one) and the late reply is ignored.

”Show more”: refresh a list in place

iOS SDK 1.0.82+ · Android SDK 1.0.54+ · Flutter and React Native: the release whose appdna_feature_parity is 1.0.82. Read Older app builds before re-authoring a live button.
A Select that builds its options from your list (above) can offer a Show more button that loads the next page without leaving the step.
  1. In the Console, add a Button and set its action to Refresh this step. Optionally fill Value sent to your app (e.g. recs_more): it reaches your handler as value, exactly as typed. Set the Select’s Max items to the longest list you will ever return (up to 50).
  2. In your app, handle onElementInteraction where action == "refresh", and return the list in dataContext, under the same key the Select repeats over, with advance: false:
What happens on the device:
  • The tapped button shows a spinner (its label hidden, its size and colours kept) until your reply lands. While any interaction on the step is in flight, the step’s other Refresh this step buttons cannot be tapped; everything else stays usable — the user can keep picking, searching, or tap Next. While the step’s first onBeforeStepRender reply is still loading, Refresh this step buttons cannot be tapped either: there is no first page yet to show more of.
  • dataContext merges into the step’s hook_data per top-level key: a key you return replaces that key’s value whole, and keys you do not return keep their value. To remove a key, set it to Kotlin null (or JSONObject.NULL) — e.g. mapOf("banner" to null). Keys are names, not paths — "a.b" is a key called a.b; to change a nested value, return its whole top-level value.
  • Everything that reads the key re-resolves: the Select’s generated options, {{hook_data.…}} text, images. dataContext is honoured on the reply to every interaction (OTP, calendar, press-and-hold…), not only refresh. If the same reply also carries fieldOptions for the Select, those win over the list.
  • null changes nothing and the button works again; no toast is shown — show your own UI if a fetch fails. With no onboarding delegate set, the tap does nothing (a debug log says so). A delegate that does not override onElementInteraction still gets the call: the default implementation returns null, so the spinner shows for that one call and then ends with no change.
  • The SDK waits at most 8 seconds for a refresh reply. After that the spinner ends, nothing changes, and a later reply is ignored. Other interactions keep today’s behaviour (no SDK deadline).
  • A reply that arrives after the user left the step (Next or Back) is dropped whole, advance included.
  • With advance: true the reply is applied first, then the step advances through the required-field check. If the user’s pick vanished from the list, the step stays, showing the new list and the validation message.
  • The data lives in memory for this flow presentation only: it is never persisted, sent to our backend, written to the responses or tracked. Keep one reply under ~256 KB.
  • A Refresh this step button inside an option’s sheet, or on a Sound button (whose tap plays its audio), never fires. Publishing warns about both.
Accumulate — return the whole list so far. The SDK replaces the key; it never appends. Return page 1 + page 2 + … under the same key, as above: a selection that is still in the list stays selected, even at a different position. A host that returns only the new page still works, but a pick from an earlier page is no longer rendered and is cleared, and a required Select then blocks until the user picks again. max must cover the longest list. Items past the Select’s Max items (default 20, at most 50) are silently not rendered; a debug log names the block, the list’s length and max. On a revisit, return the list the user last saw. When the user goes Back and returns, onBeforeStepRender runs again, and its reply wins over earlier Show more pages for the keys it sets. Keep your paging state per step and answer with the accumulated list — otherwise the list collapses to page 1 and a pick from a later page is cleared. (On iOS the same re-run also happens when a paywall shown from the flow is dismissed.)

Older app builds

A newly published flow can reach an app build with an older SDK. What a Refresh this step button does there: Ship the app build with this SDK and a refresh handler first; re-author the button after.

Behaviour changes in this release

These apply to every flow, including flows that never use Show more:
Kotlin binary compatibility. ElementInteractionResult gained dataContext, and AppliedInteraction gained dataContext and inputValuePatches. Both are data classes, so two kinds of generated method changed signature: copy / copy$default, and the synthetic default-arguments constructor that Kotlin calls whenever you construct the type with any argument left to its default, or with named arguments. Kotlin source keeps compiling, and Java callers keep every constructor they had (@JvmOverloads plus an explicit pre-1.0.51 constructor). But a Kotlin library compiled against Android SDK 1.0.53 or earlier that calls copy on either type, or constructs either type with defaulted or named arguments (e.g. ElementInteractionResult(advance = true)), must be recompiled against 1.0.54; otherwise it fails at run time with NoSuchMethodError. Your own app is recompiled when you bump the SDK, so this only matters for a prebuilt library that depends on the AppDNA SDK.
  1. An interaction reply’s inputValuePatches merge key by key onto the current answers. Before, the whole snapshot from the moment of the tap was written back, which reverted anything the user changed while the call was in flight (OTP, calendar, press-and-hold…).
  2. A late interaction reply is dropped once the user has left the step or started a new visit to it. Before, iOS always applied it — and advance: true could complete a step the user had already left — and Android applied it during the ≤ 250 ms exit animation. A slow reply no longer blocks interactions on the next step.
  3. Android catches an exception thrown by onElementInteraction, logs it and treats it as null, instead of crashing.
  4. On Android, Refresh this step buttons inside a Row, a Stack, a Section background or a Carousel page now fire; before, they silently did nothing. On iOS, Carousel pages change the same way.
  5. Flows already using Refresh this step (since iOS 1.0.79 / Android 1.0.51) now get the 8-second deadline, the two tap locks above, the spinner and — on Flutter and React Native — the 8-second minimum bridge wait (before, the bridge’s 5-second timeout: fixed on Flutter, the vetoTimeout default on React Native).
  6. A tap on the outgoing step while it slides away is refused. Before, it reached your app carrying the incoming step’s id.
  7. fieldConfigPatches / fieldOptions applied by any interaction are dropped when the same step view is reused for a new visit inside the exit animation (a very quick Back-and-forward). A normal Back then forward already dropped them.

Host data contract

Paste a sample of your dataContext into Flow settings › Host data contract in the Console. It fills the variable picker with your real paths, drives the editor preview (a repeat Select shows one option per sample item) and lets publishing warn about paths your app does not send. It is sample data for the Console only — it is never sent to devices; do not paste real personal data. Supported on: {{hook_data.…}} in the original text slots — iOS SDK 1.0.76+ · Android SDK 1.0.48+ · Flutter SDK 1.0.14+ · React Native SDK 1.0.13+. Every other field, image data templates and lists — iOS SDK 1.0.82+ · Android SDK 1.0.54+ · Flutter and React Native: the release whose appdna_feature_parity is 1.0.82.

Progress Bar Custom Colors

The progress_bar content block supports custom color configuration: Colors are configured per block in the Console. When not set, the SDK uses the app’s primary theme color.

Per-Step Progress Visibility

The progress indicator can be hidden on specific steps while still counting them in the total progress. This is useful for splash screens, permission prompts, or transition steps where the progress bar would be distracting. Configure hide_progress per step in the Console under Step Design > Logic. When enabled, the progress bar is hidden on that step but the step still contributes to the overall progress calculation (e.g., step 3 of 5 still advances progress to 60%).
This is a per-step override of the flow-level show_progress setting. If show_progress is false at the flow level, the progress bar is hidden on all steps regardless of hide_progress.

Conditional Branching

Onboarding flows support conditional branching — the next step changes based on the user’s answer. Branching is configured entirely in the Console: Example:
To configure branching:
  1. Open your flow in the Console (Onboarding > Flows)
  2. On a question step, click Add branching rule
  3. Map each answer option to a target step
  4. The SDK handles routing automatically — no code needed
All answers (including branched paths) are returned in the responses map in onOnboardingCompleted.

Auto-Tracked Events

The onboarding module automatically tracks the following events: Each event includes the flowId, stepId, and stepIndex where applicable.
The responses map passed to onOnboardingCompleted contains all user answers keyed by step ID. Use these responses to personalize the user experience or send them to your backend for analysis.

Configuration in Console

Onboarding flows are managed in the AppDNA Console:
  1. Navigate to Onboarding > Flows.
  2. Create a new flow or edit an existing one.
  3. Add steps (welcome, question, value_prop, form, interactive_chat, custom) and configure their content.
  4. Set targeting rules to control which users see the flow.
  5. Publish the flow to make it available to the SDK via the config bundle.
Flows must be published in the Console before they appear in the SDK. Draft flows are not delivered to client devices.

Full Example

Paywall trigger nodes — entitlement-aware skip + restore behavior (1.0.32+)

Onboarding flows can include paywall_trigger graph nodes that present a paywall in the middle of a flow and route the user via per-outcome targets (on_success_target, on_fail_target, on_dismiss_target). Starting in 1.0.32 the SDK ships two behavioral changes around these triggers — both controllable from the console editor.

Entitlement-aware skip (default ON)

Each paywall_trigger node carries a skip_if_subscribed flag (default true). When the SDK reaches the trigger, it checks AppDNA.billing.hasActiveSubscription() (synchronous; reads the in-memory EntitlementCache) first:
  • User has an active subscription → SDK skips presentation, emits an onboarding_paywall_skip event with reason=user_already_subscribed, and routes the flow via the configured skip target (see below).
  • User has no active subscription → SDK presents the paywall as before; outcome routing is unchanged.
This stops returning users from seeing a paywall they already paid for. Already-subscribed users move past the trigger silently. If you sell upsell paywalls (a higher tier to a user who already owns the base plan), open the trigger in the onboarding flow editor and uncheck Skip if user already has an active subscription. The trigger then always presents.
Existing flows authored before 1.0.32 require no migration: nodes without the field default to true at read time.

Subscribed-user skip routing — picking the target

The console editor exposes a dedicated dropdown directly under the Skip if user already has an active subscription toggle: When skipped (user is already subscribed), go to. Four options: The SDK resolves the target with this chain at runtime: on_subscribed_skip_target → falls back to on_success_target → falls back to “follow downstream edge”. The fallback means flows authored before 1.0.32 that worked around this by setting on_success_target = complete_flow continue to behave identically.

Warning callout in the editor

If you open a paywall_trigger that has Skip if user already has an active subscription on but the resolved skip target is “Follow downstream edge” and the next node in the graph is another paywall_trigger, the editor displays an inline warning:
Heads up: this paywall is set to skip for subscribed users, but the next step is another paywall trigger — subscribed users will still see it. Set “When skipped” to “Complete flow” to bypass the rest of the paywall chain.
One click on Complete flow in the dropdown clears the warning and fixes the chain. This is the most common authoring trap with multi-paywall flows (main paywall → winback paywall → end); the warning surfaces it before publish.

Restore now auto-dismisses + routes to success

Tapping Restore Subscription on a paywall now:
  1. Fires onPaywallRestoreCompleted(paywallId, productIds) on your AppDNAPaywallDelegate.
  2. Auto-finishes the PaywallActivity when productIds is non-empty (i.e., the restore actually found entitlements).
  3. Routes the onboarding flow via the trigger’s on_success_target — same path as a real purchase.
When the restore call succeeds but finds NO entitlements (productIds: emptyList()) the paywall stays up so the user can attempt a fresh purchase or close manually.

Hosts that handle their own dismiss

If you already finish the PaywallActivity inside your own onPaywallRestoreCompleted delegate body (e.g., to show a “Restored — tap continue when ready” overlay), the existing dispatchedDismiss flag inside PaywallActivity ensures the SDK auto-finish becomes a no-op once your finish() runs. First caller wins.

AppDNA.identify(userId) refreshes the entitlement cache

When you call AppDNA.identify(...) after the user signs in, the SDK now silently calls AppDNA.billing.refreshEntitlementCache() in the background (via the SDK’s lifecycle-bound coroutine scope). The next paywall_trigger gate then reflects the identified user’s Play Billing subscriptions instead of the prior anonymous user’s empty entitlement state. If your app completes auth out-of-band (SSO callback, OAuth web flow), you can call AppDNA.billing.refreshEntitlementCache() (suspend) or AppDNA.billing.refreshEntitlementCacheFuture() (Java-friendly) manually after auth completes — same effect, no events fired.

Migration risk callout

⚠️ Behavioral change: paywall_trigger nodes now skip presentation for subscribed users by default. If your app intentionally relies on the legacy “always present” behavior (e.g., a confirmation/legal-acceptance screen rendered as a paywall), open the trigger node in the console editor and uncheck Skip if user already has an active subscription. ⚠️ Web-side entitlements (Stripe subscriptions, manually-granted entitlements via web tooling) are NOT consulted by this gate today — only Play Billing entitlements are. If your premium users have only web-side subscriptions, leave Skip if user already has an active subscription unchecked, or implement a host-side gate via AppDNAOnboardingDelegate.onBeforeStepRender (see the async hooks doc) that consults your own backend.

Next Steps

  • Present Paywalls at the end of onboarding flows
  • Set up Billing to handle purchases triggered from onboarding
  • Learn about Offline Support for onboarding config caching

Measurement mode (wheel_picker)

wheel_picker supports an optional measurement mode for capturing a weight, height, temperature, or any custom quantity with a unit toggle. Enable it in the console by setting a measurement type (Weight, Height, Temperature, or Custom) on the wheel picker. When set, the picker renders with:
  • a unit toggle (e.g. kg ⇄ lbs, cm ⇄ in, °C ⇄ °F) that converts the value live, and
  • a measurement visual style — ruler (tick tape), gauge (radial dial), dial (rotary drum), or wheel (classic drum).
Each unit defines its own range and step; toggling converts the current value and re-clamps it to the new unit’s range.

Reported value

The SDK stores the value in the base unit (the first unit you define) so it stays stable when the user flips units. Alongside it, the SDK reports the chosen unit and the displayed value: The element-interaction callback receives { value, display_value, unit }, where value is the base-unit value. When no measurement type is set, wheel_picker behaves exactly as before.