Process data from flows in Kotlin Multiplatform SDK

When a user types into an input field, answers a quiz, or flips a toggle in a flow, the SDK passes the value to your app through its analytics callback.

Apps most often use that data to:

  • Register users on their own backend: Take the email address and the name a user entered in your onboarding flow, and create their account when the flow closes.
  • Save answers and preferences: Track what the user picked so your app can act on it later — for example, write it to their Adapty profile as custom attributes.
  • Customize future flows: Save quiz answers as custom attributes, then target a later placement so each segment gets a different flow, or a different paywall inside it.
  • Feed third-party analytics platforms: Forward answers to Amplitude, Mixpanel, or whichever product analytics you run.

Inputs and selectable groups report their values automatically. To tell inputs apart in your code, give each input a meaningful Element ID and each selectable group a Group ID in the builder.

Before you start

You need:

  • Adapty SDK v4 or later: The flow callbacks don’t exist in earlier versions.
  • A flow built in the Flow & Paywall Builder: Only flows report input values through this callback.
  • A recently published flow version: A flow reports input values only if you published it after this feature became available. If nothing reaches your app, publish a new version of the flow and try again.

Receive input values

Input values reach the same callback as every other analytics event from a flow, under the event name flow_user_input. Override flowViewDidReceiveAnalyticEvent on the observer you register with AdaptyUI.setFlowsEventsObserver:

AdaptyUI.setFlowsEventsObserver(object : AdaptyUIFlowsEventsObserver {

    override fun flowViewDidReceiveAnalyticEvent(
        view: AdaptyUIFlowView,
        name: String,
        paramsJsonString: String,
    ) {
        handleFlowInput(name, paramsJsonString)
    }
})

The flowViewDidReceiveAnalyticEvent callback delivers all analytics events from a flow, including screen views. The event parameters arrive as one JSON string in paramsJsonString; decode it once and read the fields.

  • The name parameter contains the event name. To filter for user input events, compare name to flow_user_input.
  • The element_type parameter names the element category.
  • The value of the input is stored in different parameters depending on the element type:
    • Text fields, pickers, and toggles store the user’s input in value
    • Selectable groups report the active options in item_ids and item_titles
private fun handleFlowInput(name: String, paramsJsonString: String) {
    if (name != "flow_user_input") return

    val params = Json.parseToJsonElement(paramsJsonString).jsonObject
    val elementId = params["element_id"]?.jsonPrimitive?.content ?: return

    // The screen the input sits on. Pair it with elementId to tell apart
    // two fields that share an Element ID on different screens.
    val screenId = params["instanceId"]?.jsonPrimitive?.contentOrNull

    when (params["element_type"]?.jsonPrimitive?.content) {
        "text_input", "email_input", "number_input", "phone_input" -> {
            val text = params["value"]?.jsonPrimitive?.content
        }
        "date_picker", "time_picker", "date_time_picker" -> {
            // Unix time in milliseconds. Read it as a double — Android serializes numbers that way.
            val millis = params["value"]?.jsonPrimitive?.double?.toLong()
        }
        "single_choice" -> {
            val optionId = params["item_ids"]?.jsonArray?.firstOrNull()?.jsonPrimitive?.content
        }
        "multi_choice" -> {
            val optionIds = params["item_ids"]?.jsonArray?.map { it.jsonPrimitive.content }
        }
        "toggle" -> {
            val isOn = params["value"]?.jsonPrimitive?.boolean
        }
    }
}

To confirm the callback fires, interact with the input in a test build of your own app. If your callback handler doesn’t receive an event, check the prerequisites. Make sure that the flow was published after this feature became available.

When your app receives the input

Important

The default input or selection value never reaches your app through this callback. If a user accepts the option marked Set as default and moves on, no event fires. Don’t read the missing event as “no answer” — the user simply left the default value in place.

The following elements trigger this event:

  • Text, email, number, and phone fields
  • Date, time, and date-time pickers
  • Single-choice and multi-choice selectable groups, and toggles

The following don’t:

  • Password fields, product selections, and tab switches
  • Inputs inside a Header element, which is shared across screens
  • Selectable groups with duplicate or missing option Element IDs, or with a Group ID reused on another screen. Such a group sends nothing at all, rather than part of the answer.

The event is triggered when:

  • A field loses focus. A cleared field reports an empty string; a field the user never edited reports nothing. A placeholder is not a value. If the user returns to the field, edits it, and leaves it again, a second event follows.
  • The user closes a picker after picking a new value. Closing it unchanged reports nothing.
  • The user taps an option or a toggle. A multi-choice event lists every selected option, so deselecting the last one sends two empty arrays.

The event is not triggered when:

  • The user types. There is no keystroke stream, only the value the field holds when focus leaves it.
  • The user submits or closes the flow. A value still being edited at that moment can be lost; the delivery limitations section covers how to design the last screen around this.
  • A value is set without user interaction. An option marked Set as default is pre-selected when the screen opens, and a Set Variable action can select an option or fill an input from another interaction. Neither sends an event; a pre-filled input is reported only once the user edits it.

What you receive

The callback delivers two events. Filter by name to only display flow_user_input events. The response JSON payload looks like this:

{
  "name": "flow_user_input",
  "instanceId": "scr_registration",
  "isBackendEvent": false,
  "isCustomerEvent": true,
  "element_id": "email",
  "element_type": "email_input",
  "value": "jane@example.com"
}
ParameterDescription
nameflow_user_input for input events, flow_screen_showed for screen views.
instanceIdThe ID of the input’s screen. Element IDs are unique within a screen, not across the flow. If your flow has inputs on more than one screen, pair instanceId with element_id when filtering events.
element_idThe Element ID of the input, or the Group ID of the selectable group.
element_typeThe type of element that sent the event. It determines which of the parameters below carry the input value.
valueText fields, pickers, and toggles only. The input value: a string for text fields, an integer for pickers, a boolean for toggles.
item_idsSingle-choice and multi-choice selectable groups only. The Element IDs of the selected options, in the order the options appear in the builder. One entry for a single-choice group; any number for a multi-choice group.
item_titlesSingle-choice and multi-choice selectable groups only. The titles of the options listed in item_ids, in the same order. Never empty: an option without a title reports its ID instead.
isCustomerEventUtility flag, always true for this event. It marks events the SDK delivers to your callback. Useful if one handler forwards every flow event to your analytics and you gate on the flag rather than on name.
isBackendEventUtility flag, always false for this event. It marks events Adapty also records for its own analytics. false confirms that what users enter reaches your app and nowhere else — Adapty doesn’t receive or store it.

What each element reports:

In the builderelement_typeParameter that stores valueWhat it holds
Text, Number, Phone number inputtext_input, number_input, phone_inputvalueThe raw string the user typed. Numbers arrive as strings, not as numeric types.
E-mail inputemail_inputvalueThe raw string the user typed, even if it failed the builder’s format validation. Validate it on your side before you use it.
Password inputnonenoneSends no event.
Date inputdate_pickervalueUnix time in milliseconds, as an integer, at local midnight of the selected date.
Time inputtime_pickervalueUnix time in milliseconds, as an integer, rounded down to the minute.
Date & Time inputdate_picker and time_pickervalueTwo elements, a date picker and a time picker. Each sends its own event.
Input switched to Date & Time in the Type dropdowndate_time_pickervalueUnix time in milliseconds, as an integer, rounded down to the minute.
Single choice groupsingle_choiceitem_ids, item_titlesTwo arrays. item_ids: an array with the selected option’s Element ID. item_titles: an array with that option’s title.
Multi-choice groupmulti_choiceitem_ids, item_titlesTwo arrays. item_ids: the Element IDs of every selected option, in the order the options appear in the builder. item_titles: their titles, in the same order. Both arrays are empty when nothing is selected.
Toggle grouptogglevalueA boolean.

To branch on an answer, compare item_ids, not item_titles. A title is derived: the option’s Element Title if you set one, otherwise its text in your default locale, otherwise its Element ID. A user who read the flow in another language saw different text.

Event examples

These examples show the properties available on each event, with illustrative values in comments.

Text, email, number, and phone input (Click to expand)
override fun flowViewDidReceiveAnalyticEvent(
    view: AdaptyUIFlowView,
    name: String,
    paramsJsonString: String,
) {
    name                       // "flow_user_input"
    paramsJsonString           // "{\"name\":\"flow_user_input\",\"instanceId\":\"scr_J260KU5q\",\"isBackendEvent\":false,\"isCustomerEvent\":true,\"element_id\":\"email\",\"element_type\":\"email_input\",\"value\":\"jane@example.com\"}"

    // paramsJsonString, once decoded:
    params["name"]             // "flow_user_input"
    params["instanceId"]       // "scr_J260KU5q"
    params["isCustomerEvent"]  // true
    params["isBackendEvent"]   // false
    params["element_id"]       // "email"
    params["element_type"]     // "email_input"
    params["value"]            // "jane@example.com"
}
Date, time, and date-time pickers (Click to expand)
override fun flowViewDidReceiveAnalyticEvent(
    view: AdaptyUIFlowView,
    name: String,
    paramsJsonString: String,
) {
    // paramsJsonString, once decoded:
    params["element_id"]       // "birthday"
    params["element_type"]     // "date_picker"
    params["value"]            // 645408000000   (Unix milliseconds — 1990-06-15, local midnight)
}
Single choice (Click to expand)
override fun flowViewDidReceiveAnalyticEvent(
    view: AdaptyUIFlowView,
    name: String,
    paramsJsonString: String,
) {
    // paramsJsonString, once decoded:
    params["element_id"]       // "experience"
    params["element_type"]     // "single_choice"
    params["item_ids"]         // ["pro"]
    params["item_titles"]      // ["I train professionally"]
}
Multi choice (Click to expand)
override fun flowViewDidReceiveAnalyticEvent(
    view: AdaptyUIFlowView,
    name: String,
    paramsJsonString: String,
) {
    // paramsJsonString, once decoded:
    params["element_id"]       // "interests"
    params["element_type"]     // "multi_choice"
    params["item_ids"]         // ["sports", "music"]
    params["item_titles"]      // ["Sports", "Music"]
}
Toggle (Click to expand)
override fun flowViewDidReceiveAnalyticEvent(
    view: AdaptyUIFlowView,
    name: String,
    paramsJsonString: String,
) {
    // paramsJsonString, once decoded:
    params["element_id"]       // "reminders"
    params["element_type"]     // "toggle"
    params["value"]            // true
}

Delivery and limitations

Warning

Flows send raw values — email addresses, phone numbers, and whatever else a user types. Treat everything the analytics callback delivers as personal data, and don’t write it anywhere you wouldn’t write a user’s email address.

  • Last value wins: You get one event per field, carrying the value the user settled on rather than a keystroke-by-keystroke stream. If they edit a field, only the last version reaches you.
  • No submit guarantee: Values reach you as users move through the flow, and a user can leave at any point. Wait for the flow to close before treating a set of answers as complete.
  • Best-effort delivery: If a user closes the flow or minimizes the app while a field is still focused or a picker is still open, that value can be lost.

To make the last field’s value reliable, end the flow with a screen that has no inputs and no pickers, and close the flow from an explicit user action rather than automatically when that screen appears. Moving to the final screen takes focus off the previous field, which is what sends its value.

Store each input value as your observer receives it, and send the complete set when the flow closes. To catch that moment, override flowViewDidDisappear on the same observer. It runs when the flow view is dismissed, whether the user finished the flow or closed it partway.

Use cases

Register users on your backend

Collect values as they arrive and send them once the flow closes, so that one request carries a complete set of answers.

The view disappears whether a user finished the flow or abandoned it partway. Guard on the fields you need before you call your backend.

The flow can’t display errors from your backend. The callback has no return value, and the SDK has no method that sends data into a running flow. If registration fails, for example because the email is already in use, show the error in your own UI after the flow closes.

class MyFlowsEventsObserver : AdaptyUIFlowsEventsObserver {

    private val flowAnswers = mutableMapOf<String, String>()

    override fun flowViewDidReceiveAnalyticEvent(
        view: AdaptyUIFlowView,
        name: String,
        paramsJsonString: String,
    ) {
        if (name != "flow_user_input") return

        val params = Json.parseToJsonElement(paramsJsonString).jsonObject
        val elementId = params["element_id"]?.jsonPrimitive?.content ?: return
        val value = params["value"]?.jsonPrimitive?.contentOrNull ?: return

        flowAnswers[elementId] = value
    }

    override fun flowViewDidDisappear(view: AdaptyUIFlowView) {
        if (!flowAnswers.containsKey("email")) return

        // Send flowAnswers to your backend here to create the account.

        flowAnswers.clear()
    }
}

Enrich user profiles with data

To link what a user entered to their profile and avoid asking for the same details twice, update the user profile as the values come in.

For example, if your flow has a text input with the Element ID name and an email input with the Element ID email:

private fun handleFlowInput(name: String, paramsJsonString: String) {
    if (name != "flow_user_input") return

    val params = Json.parseToJsonElement(paramsJsonString).jsonObject
    val value = params["value"]?.jsonPrimitive?.contentOrNull ?: return

    val builder = AdaptyProfileParameters.Builder()

    when (params["element_id"]?.jsonPrimitive?.content) {
        "name" -> builder.withFirstName(value)
        "email" -> builder.withEmail(value)
        else -> return
    }

    mainUiScope.launch {
        Adapty.updateProfile(builder.build())
            .onError { error ->
                // handle the error
            }
    }
}

Customize flows shown later

Quiz answers can also decide what a user sees at a later placement — a different flow, or a different paywall inside it.

For example, ask users about their experience with sport in your onboarding flow, then show each group its own flow with different products and copy.

  1. Add a quiz to your flow. Give the selectable group the Group ID experience, and each option a meaningful Element ID.
  2. Handle the answers and set custom attributes for the user.
private fun handleFlowInput(name: String, paramsJsonString: String) {
    if (name != "flow_user_input") return

    val params = Json.parseToJsonElement(paramsJsonString).jsonObject
    if (params["element_id"]?.jsonPrimitive?.content != "experience") return

    val optionId = params["item_ids"]?.jsonArray?.firstOrNull()
        ?.jsonPrimitive?.contentOrNull ?: return

    val builder = AdaptyProfileParameters.Builder()
    // Set the custom attribute 'experience' to the option the user selected
    // (beginner, amateur, or pro).
    builder.withCustomAttribute("experience", optionId)

    mainUiScope.launch {
        Adapty.updateProfile(builder.build())
            .onError { error ->
                // handle the error
            }
    }
}
  1. Create a segment for each custom attribute value.
  2. Create a placement and add an audience for each segment.
  3. Display the flow for that placement in your app.