---
title: "Process data from flows in iOS SDK"
description: "Save and use data your users enter in flows in your iOS app with Adapty SDK."
---

> **AI agents**: to search Adapty docs faster and with fewer tokens, install the Adapty skill. Claude Code (self-updating via plugin): `claude plugin marketplace add adaptyteam/adapty-skills && claude plugin install adapty-skills@adapty` — other tools: `npx skills add adaptyteam/adapty-skills --all`

When a user types into an input field, answers a quiz, or flips a toggle in a [flow](adapty-flow-builder), 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](setting-user-attributes) 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](adapty-flow-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`. Register the callback alongside your other flow event handlers.

<Tabs>
<TabItem value="swiftui" label="SwiftUI" default>

Pass a `didReceiveAnalyticEvent` closure to the `.flow` modifier:

```swift showLineNumbers title="Swift"
Text("Hello, AdaptyUI!")
    .flow(
        isPresented: $flowPresented,
        flowConfiguration: flowConfiguration,
        didFinishPurchase: { product, purchaseResult in /* handle the event */ },
        didFailPurchase: { product, error in /* handle the error */ },
        didFinishRestore: { profile in /* handle the event */ },
        didFailRestore: { error in /* handle the error */ },
        didReceiveError: { error in flowPresented = false },
        didReceiveAnalyticEvent: { name, params in
            handleFlowInput(name: name, params: params)
        }
    )
```

</TabItem>
<TabItem value="uikit" label="UIKit">

Implement the method on your `AdaptyFlowControllerDelegate`:

```swift showLineNumbers title="Swift"
func flowController(
    _ controller: AdaptyFlowController,
    didReceiveAnalyticEvent name: String,
    params: [String: any Sendable]
) {
    handleFlowInput(name: name, params: params)
}
```

</TabItem>
</Tabs>

The closure and the delegate method receive the same two arguments, so the code that reads the value is the same either way.

The `didReceiveAnalyticEvent` callback delivers all analytics events from a flow, including [screen views](ios-flow-screen-views).
- 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`

```swift showLineNumbers title="Swift"
func handleFlowInput(name: String, params: [String: any Sendable]) {
    guard name == "flow_user_input",
          let elementId = params["element_id"] as? String,
          let elementType = params["element_type"] as? String
    else { return }

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

    switch elementType {
    case "text_input", "email_input", "number_input", "phone_input":
        let text = params["value"] as? String
    case "date_picker", "time_picker", "date_time_picker":
        // Integer Unix time in milliseconds, not the seconds Date expects.
        let date = (params["value"] as? Int).map { Date(timeIntervalSince1970: Double($0) / 1000) }
    case "single_choice":
        let optionId = (params["item_ids"] as? [String])?.first
    case "multi_choice":
        let optionIds = params["item_ids"] as? [String]
    case "toggle":
        let isOn = params["value"] as? Bool
    default:
        break
    }
}
```

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](#before-you-start). 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](#delivery-and-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:

```json
{
  "name": "flow_user_input",
  "instanceId": "scr_registration",
  "isBackendEvent": false,
  "isCustomerEvent": true,
  "element_id": "email",
  "element_type": "email_input",
  "value": "jane@example.com"
}
```

| Parameter         | Description |
|:------------------|:------------|
| `name`            | `flow_user_input` for input events, `flow_screen_showed` for screen views. |
| `instanceId`      | The 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_id`      | The **Element ID** of the [input](builder-inputs-and-forms), or the **Group ID** of the [selectable group](flow-selectable-elements). |
| `element_type`    | The type of element that sent the event. It determines which of the parameters below carry the input value. |
| `value`           | **Text fields, pickers, and toggles only.** The input value: a string for text fields, an integer for pickers, a boolean for toggles. |
| `item_ids`        | **Single-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_titles`     | **Single-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. |
| `isCustomerEvent` | Utility 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`. |
| `isBackendEvent`  | Utility 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 builder | `element_type` | Parameter that stores value | What it holds |
|:---------------|:---------------|:----------------------------|:--------------|
| **Text**, **Number**, **Phone number** input | `text_input`, `number_input`, `phone_input` | `value` | The raw string the user typed. Numbers arrive as strings, not as numeric types. |
| **E-mail** input | `email_input` | `value` | The raw string the user typed, even if it failed the builder's format validation. Validate it on your side before you use it. |
| **Password** input | none | none | Sends no event. |
| **Date** input | `date_picker` | `value` | Unix time in milliseconds, as an integer, at local midnight of the selected date. |
| **Time** input | `time_picker` | `value` | Unix time in milliseconds, as an integer, rounded down to the minute. |
| **Date & Time** input | `date_picker` and `time_picker` | `value` | Two elements, a date picker and a time picker. Each sends its own event. |
| Input switched to **Date & Time** in the **Type** dropdown | `date_time_picker` | `value` | Unix time in milliseconds, as an integer, rounded down to the minute. |
| **Single choice** group | `single_choice` | `item_ids`, `item_titles` | Two arrays. `item_ids`: an array with the selected option's Element ID. `item_titles`: an array with that option's title. |
| **Multi-choice** group | `multi_choice` | `item_ids`, `item_titles` | Two 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** group | `toggle` | `value` | A 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.

<Details>
<summary>Text, email, number, and phone input (Click to expand)</summary>

```swift
func handleFlowInput(name: String, params: [String: any Sendable]) {
    name;                      // "flow_user_input"
    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"   (String)
}
```
</Details>

<Details>
<summary>Date, time, and date-time pickers (Click to expand)</summary>

```swift
func handleFlowInput(name: String, params: [String: any Sendable]) {
    params["element_id"];      // "birthday"
    params["element_type"];    // "date_picker"
    params["value"];           // 645408000000   (Unix milliseconds — 1990-06-15, local midnight)
}
```
</Details>

<Details>
<summary>Single choice (Click to expand)</summary>

```swift
func handleFlowInput(name: String, params: [String: any Sendable]) {
    params["element_id"];      // "experience"
    params["element_type"];    // "single_choice"
    params["item_ids"];        // ["pro"]
    params["item_titles"];     // ["I train professionally"]
}
```
</Details>

<Details>
<summary>Multi choice (Click to expand)</summary>

```swift
func handleFlowInput(name: String, params: [String: any Sendable]) {
    params["element_id"];      // "interests"
    params["element_type"];    // "multi_choice"
    params["item_ids"];        // ["sports", "music"]
    params["item_titles"];     // ["Sports", "Music"]
}
```
</Details>

<Details>
<summary>Toggle (Click to expand)</summary>

```swift
func handleFlowInput(name: String, params: [String: any Sendable]) {
    params["element_id"];      // "reminders"
    params["element_type"];    // "toggle"
    params["value"];           // true   (Bool)
}
```
</Details>

## 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 handler receives it, and send the complete set when the flow closes. To catch that moment, implement `flowControllerDidDisappear` on your `AdaptyFlowControllerDelegate` in UIKit, or pass a `didDisappear` closure to the `.flow` modifier in SwiftUI. Both run after the flow's view has left the screen, whether the user finished the flow or dismissed it.

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

```swift showLineNumbers title="Swift"
private var flowAnswers: [String: String] = [:]

func handleFlowInput(name: String, params: [String: any Sendable]) {
    guard name == "flow_user_input",
          let elementId = params["element_id"] as? String,
          let value = params["value"] as? String
    else { return }

    flowAnswers[elementId] = value
}

func flowControllerDidDisappear(_ controller: AdaptyFlowController) {
    guard flowAnswers["email"] != nil else { return }

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

    flowAnswers.removeAll()
}
```

### 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](setting-user-attributes) 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`:

```swift showLineNumbers title="Swift"
func handleFlowInput(name: String, params: [String: any Sendable]) {
    guard name == "flow_user_input",
          let elementId = params["element_id"] as? String,
          let value = params["value"] as? String
    else { return }

    let builder = AdaptyProfileParameters.Builder()

    switch elementId {
    case "name":
        builder.with(firstName: value)
    case "email":
        builder.with(email: value)
    default:
        return
    }

    // Delegate methods are synchronous; kick off the async update in a Task.
    Task {
        do {
            try await Adapty.updateProfile(params: builder.build())
        } catch {
            // handle the error
        }
    }
}
```

### Customize flows shown later

Quiz answers can also decide what a user sees at a later [placement](placements) — 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](onboarding-quizzes) to your flow. Give the [selectable group](flow-selectable-elements) the Group ID `experience`, and each option a meaningful Element ID.
2. Handle the answers and [set custom attributes](setting-user-attributes) for the user.

```swift showLineNumbers title="Swift"
func handleFlowInput(name: String, params: [String: any Sendable]) {
    guard name == "flow_user_input",
          params["element_id"] as? String == "experience",
          let optionId = (params["item_ids"] as? [String])?.first
    else { return }

    let builder = AdaptyProfileParameters.Builder()
    // Set the custom attribute 'experience' to the option the user selected
    // (beginner, amateur, or pro).
    try? builder.with(customAttribute: optionId, forKey: "experience")

    Task {
        do {
            try await Adapty.updateProfile(params: builder.build())
        } catch {
            // handle the error
        }
    }
}
```

3. [Create a segment](segments) for each custom attribute value.
4. Create a [placement](placements) and add an [audience](audience) for each segment.
5. [Display the flow](ios-present-paywalls) for that placement in your app.