> ## Documentation Index
> Fetch the complete documentation index at: https://docs.appdna.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# iOS Onboarding

> Present server-driven onboarding flows

<Info>
  **Supported on:** iOS SDK `1.0.61+`
</Info>

<Tip>
  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](/sdks/ios/experiments#servable-surface-experiments).
</Tip>

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:

```swift theme={null}
let presented = AppDNA.presentOnboarding(
    flowId: "main_flow",
    from: viewController,
    delegate: self
)

if !presented {
    print("Flow config not available — check Console or network")
}
```

The method returns `false` if the flow configuration is not available (e.g., config has not loaded yet or the flow ID is invalid).

<Info>
  If `flowId` is `nil`, 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.
</Info>

## Module Access

Access the onboarding module directly:

```swift theme={null}
let onboarding = AppDNA.onboarding
```

### Module Methods

| Method        | Signature                                                                               | Description                       |
| ------------- | --------------------------------------------------------------------------------------- | --------------------------------- |
| `present`     | `present(flowId: String?, from: UIViewController, context: OnboardingContext?) -> Bool` | Present an onboarding flow        |
| `setDelegate` | `setDelegate(_ delegate: AppDNAOnboardingDelegate?)`                                    | Set a delegate for flow callbacks |

## OnboardingContext

Pass additional context when presenting a flow:

```swift theme={null}
let context = OnboardingContext(
    source: "app_launch",
    campaign: "winter_2025",
    referrer: "social_ad",
    userProperties: ["locale": "en_US"],
    experimentOverrides: ["onboarding_variant": "b"]
)

AppDNA.onboarding.present(
    flowId: "main_flow",
    from: viewController,
    context: context
)
```

| Property              | Type                | Description                                         |
| --------------------- | ------------------- | --------------------------------------------------- |
| `source`              | `String?`           | Where the flow was triggered from                   |
| `campaign`            | `String?`           | Attribution campaign identifier                     |
| `referrer`            | `String?`           | Referral source                                     |
| `userProperties`      | `[String: Any]?`    | Additional user properties for personalization      |
| `experimentOverrides` | `[String: String]?` | Override experiment variant assignments for testing |

## 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](/sdks/ios/paywall#appdnapaywalldelegate) for the full delegate surface.

## AppDNAOnboardingDelegate

Implement the delegate protocol to respond to onboarding flow events:

```swift theme={null}
protocol AppDNAOnboardingDelegate {
    func onOnboardingStarted(flowId: String)
    func onOnboardingStepChanged(flowId: String, stepId: String, stepIndex: Int, totalSteps: Int)
    func onOnboardingCompleted(flowId: String, responses: [String: Any])
    func onOnboardingDismissed(flowId: String, atStep: Int)

    // Async hooks (optional — default implementations provided)
    func onBeforeStepAdvance(flowId: String, fromStepId: String, stepIndex: Int, stepType: String, responses: [String: Any], stepData: [String: Any]?) async -> StepAdvanceResult
    func onBeforeStepRender(flowId: String, stepId: String, stepIndex: Int, stepType: String, responses: [String: Any]) async -> StepConfigOverride?

    // Backend-interactive elements (1.0.67+) — fires DURING render when an interactive element acts
    func onElementInteraction(flowId: String, stepId: String, blockId: String, action: String, value: String?, inputValues: [String: Any]) async -> ElementInteractionResult?
}
```

### Example Implementation

```swift theme={null}
class OnboardingHandler: AppDNAOnboardingDelegate {
    func onOnboardingStarted(flowId: String) {
        print("Onboarding started: \(flowId)")
    }

    func onOnboardingStepChanged(
        flowId: String,
        stepId: String,
        stepIndex: Int,
        totalSteps: Int
    ) {
        print("Step \(stepIndex + 1)/\(totalSteps): \(stepId)")
        // Update progress indicator
    }

    func onOnboardingCompleted(flowId: String, responses: [String: Any]) {
        print("Onboarding completed: \(flowId)")
        print("User responses: \(responses)")
        // Navigate to main app screen
        // Use responses to personalize the experience
    }

    func onOnboardingDismissed(flowId: String, atStep: Int) {
        print("Onboarding dismissed at step \(atStep)")
        // Handle early exit — maybe show again later
    }
}
```

### Backend-interactive elements

Hook: `onElementInteraction`.

<Info>Available on iOS SDK `1.0.67+` / Android SDK `1.0.39+`.</Info>

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.

```swift theme={null}
func onElementInteraction(
    flowId: String,
    stepId: String,
    blockId: String,        // the interacting block's id
    action: String,         // canonical per-element string — see the table below
    value: String?,         // optional payload (e.g. the tapped value)
    inputValues: [String: Any]   // live snapshot of the step's collected values
) async -> ElementInteractionResult?
```

Each interactive element sends a fixed `action` (identical on iOS and Android) with an optional `value`:

| Element           | `action`                              | `value`                                                       |
| ----------------- | ------------------------------------- | ------------------------------------------------------------- |
| Calendar          | `day_selected`                        | the tapped day of the month                                   |
| OTP input         | `otp_entered`                         | the entered code (fires when complete)                        |
| Memory match      | `pair_matched`, then `completed`      | the matched card's symbol / —                                 |
| Press-and-hold    | `confirmed`                           | —                                                             |
| Measurement wheel | `value_changed`                       | the value in the **base unit** (fires on commit)              |
| Health connect    | `health_connect`                      | the provider (`apple_health` on iOS, `google_fit` on Android) |
| Settings footer   | `toggle_dark_mode`, `switch_language` | the new value                                                 |

Return an `ElementInteractionResult` to update the rendered step, or `nil` to do nothing (the default):

```swift theme={null}
public struct ElementInteractionResult {
    // Per-block field_config patches to merge + re-render, keyed blockId → (key → value)
    public var fieldConfigPatches: [String: [String: Any]]?
    // Patches merged into the step's inputValues, keyed by field_id
    public var inputValuePatches: [String: Any]?
    // When true, advance to the next step after handling the interaction
    public var advance: Bool
}
```

Example — confirm a health-data connection with your backend, then flip the card to its connected state in place:

```swift theme={null}
func onElementInteraction(
    flowId: String,
    stepId: String,
    blockId: String,
    action: String,
    value: String?,
    inputValues: [String: Any]
) async -> ElementInteractionResult? {
    switch action {
    case "health_connect":
        let granted = await healthClient.requestAuthorization()
        guard granted else { return nil }   // leave the card unchanged
        return ElementInteractionResult(
            fieldConfigPatches: [blockId: ["connected": true]],
            inputValuePatches: ["health_connected": true]
        )
    default:
        return nil
    }
}
```

`fieldConfigPatches` are layered as read-time overrides on top of the (immutable) block, so the element re-renders with the new state while iOS and Android 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 Info.plist keys (your app)

The SDK only requests a permission when your app declares its usage-description string in `Info.plist`. If the key is missing, the SDK **skips the request safely** (no crash) and reports the permission as unavailable. Add the keys for the types you use:

| Permission    | Info.plist key                                                                     |
| ------------- | ---------------------------------------------------------------------------------- |
| Camera        | `NSCameraUsageDescription`                                                         |
| Microphone    | `NSMicrophoneUsageDescription`                                                     |
| Photos        | `NSPhotoLibraryUsageDescription`                                                   |
| Location      | `NSLocationWhenInUseUsageDescription`                                              |
| Contacts      | `NSContactsUsageDescription`                                                       |
| App Tracking  | `NSUserTrackingUsageDescription`                                                   |
| Calendar      | `NSCalendarsFullAccessUsageDescription` (iOS 17+) or `NSCalendarsUsageDescription` |
| Notifications | *(none required)*                                                                  |

App Tracking additionally requires iOS 14.5+ and is only requested while the app is active.

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

```swift theme={null}
// Observe the outcome
func onPermissionResult(flowId: String, stepId: String, permissionType: String, granted: Bool)

// Optionally handle the request yourself instead of the SDK prompting
func onPermissionRequest(_ permissionType: String) async -> PermissionHandling?
// return .handledByHost(granted: Bool) to take over, or .proceed / nil to let the SDK prompt
```

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 Type          | Description                                                    |
| ------------------ | -------------------------------------------------------------- |
| `welcome`          | Welcome screen with title, subtitle, and hero image            |
| `question`         | Single-select or multi-select question for user input          |
| `value_prop`       | Value proposition screen highlighting a key feature or benefit |
| `form`             | Structured form with multiple native input fields              |
| `interactive_chat` | AI-powered conversational step                                 |
| `custom`           | Custom HTML or native content rendered via a template          |

<Note>
  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.
</Note>

## Content Blocks

Each onboarding step is composed of content blocks that control the visual layout. Blocks are configured in the Console and rendered natively by the SDK. The following block types are available:

| Block Type          | Description                                                   |
| ------------------- | ------------------------------------------------------------- |
| `title`             | Primary heading text                                          |
| `subtitle`          | Secondary descriptive text                                    |
| `image`             | Static image with sizing and corner radius                    |
| `lottie`            | Lottie animation (JSON or dotLottie)                          |
| `rive`              | Rive state-machine animation                                  |
| `video`             | Inline video (MP4, HLS) with autoplay and loop options        |
| `button`            | Tappable button with configurable action                      |
| `option_list`       | List of selectable options (single or multi-select)           |
| `form`              | Form input group (see Form Steps below)                       |
| `page_indicator`    | Dot or bar indicator showing current step progress            |
| `wheel_picker`      | Scrollable wheel-style picker for value selection             |
| `pulsing_avatar`    | Animated avatar with a pulsing ring effect                    |
| `social_login`      | Social sign-in buttons (Apple, Google, etc.)                  |
| `timeline`          | Vertical timeline with labeled milestones                     |
| `animated_loading`  | Skeleton or spinner loading animation between steps           |
| `countdown_timer`   | Countdown timer with configurable duration and expiry action  |
| `rating`            | Star or emoji rating selector                                 |
| `rich_text`         | Markdown-style rich text with inline formatting               |
| `progress_bar`      | Horizontal progress bar with percentage or label              |
| `circular_gauge`    | Circular progress indicator with value label                  |
| `date_wheel_picker` | Native date wheel picker (day/month/year columns)             |
| `stack`             | Vertical container that groups child blocks                   |
| `row`               | Horizontal container that arranges child blocks side by side  |
| `custom_view`       | Host-app-provided SwiftUI view (see Custom View Registration) |
| `star_background`   | Animated starfield or particle background effect              |
| `pricing_card`      | Product pricing card with plan details and CTA                |

<Info>
  Blocks are configured entirely in the Console. The SDK renders them automatically -- no code is needed unless you register custom views.
</Info>

### Advanced content blocks

<Info>Available on iOS SDK `1.0.67+` / Android SDK `1.0.39+`.</Info>

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`](#backend-interactive-elements) when the user acts on them.

| Block Type           | Renders                                                                                                                               | Key `field_config` fields                                                                                                |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `otp_input`          | A row of boxed single-character cells for a one-time / verification code; the next empty cell is accent-highlighted. **Interactive.** | `otp_length` (cell count, 2–10, default 6), `otp_value` (preview code); live value read from `field_id`                  |
| `warning_banner`     | A tinted rounded card with a leading icon and a message line.                                                                         | `banner_variant` (`warning` \| `error` \| `info` \| `success`), `banner_icon` (emoji override); `text` is the message    |
| `password_strength`  | Four segmented bars filling left-to-right with a strength label beneath.                                                              | `strength_level` (0–4), `strength_label` (label override)                                                                |
| `speech_bubble`      | A rounded mascot / dialogue card with a downward tail.                                                                                | `bubble_tail` (`left` \| `center` \| `right`); `text` is the bubble copy                                                 |
| `feedback_panel`     | A quiz-result panel: circled icon, bold headline, optional detail line.                                                               | `feedback_state` (`correct` \| `wrong` \| `info`), `feedback_detail`; `text` overrides the headline                      |
| `summary_screen`     | An optional headline above a grid of stat cards.                                                                                      | `summary_stats` (array of `{ value, label, color? }`); `text` is the headline                                            |
| `press_hold_confirm` | A full-width pill that fills as the user presses and holds. **Interactive.**                                                          | `hold_progress` (0–1 static fill); `text` is the label (default "Hold to confirm")                                       |
| `health_connect`     | A tappable card to connect health data — Apple Health on iOS, Google Fit on Android (provider is platform-fixed). **Interactive.**    | `connected` (bool → green check vs chevron), `health_subtitle`; `text` overrides the title                               |
| `settings_footer`    | A footer row with a dark-mode capsule toggle and a language-switcher pill. **Interactive.**                                           | `dark_mode` (bool), `language` (pill label)                                                                              |
| `memory_match`       | A grid of pair-match cards, each face-down, face-up, or matched. **Interactive.**                                                     | `match_columns` (2–5), `match_cards` (array of `{ symbol, state }`, state `down` \| `up` \| `matched`)                   |
| `calendar_month`     | A single-month calendar with weekday header and a day grid. **Interactive.**                                                          | `month_label`, `days_in_month`, `start_offset` (weekday of the 1st, 0–6), `selected_days` (array of ints), `today` (int) |

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

| Action       | What the SDK does                                                                                                                                 |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `next`       | Advances to the next step. Payload includes all collected `inputValues` + `toggle_*` values.                                                      |
| `skip`       | Skips the step (subject to `skip_enabled` config).                                                                                                |
| `link`       | Opens `action_value` (a URL) in the in-app browser. Step does not advance.                                                                        |
| `permission` | Requests the real OS permission for the step's configured permission type, records granted/denied, and advances. See [Permissions](#permissions). |

### 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 `.block(message:)` while async auth runs, then return `.proceed` / `.proceedWithData` when the backend responds.

<Warning>
  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.
</Warning>

| Action                | Typical button label                                                            | Payload to your handler                                                                                                                                                                                                                         |
| --------------------- | ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `login`               | "Sign In"                                                                       | `{action: "login", email, password}`                                                                                                                                                                                                            |
| `register`            | "Sign Up" / "Create Account"                                                    | `{action: "register", email, password, ...optional fields}`                                                                                                                                                                                     |
| `social_login`        | "Continue with Google" / Apple / etc.                                           | `{action: "social_login", provider, ...inputs}`                                                                                                                                                                                                 |
| `email_login`         | "Continue with Email" (the email provider button inside a `social_login` block) | `{action: "email_login", email, password, ...inputs}` — added in v1.0.60. Email button still emits `social_login` in parallel one final release for back-compat; will be removed in v1.1.0. Migrate to `email_login` for clean handler routing. |
| `reset_password`      | "Forgot password?"                                                              | `{action: "reset_password", email}`                                                                                                                                                                                                             |
| `magic_link`          | "Email me a sign-in link"                                                       | `{action: "magic_link", email}`                                                                                                                                                                                                                 |
| `request_otp`         | "Send code"                                                                     | `{action: "request_otp", [channel], [recipient]}` — see channel resolution below                                                                                                                                                                |
| `verify_otp`          | "Verify"                                                                        | `{action: "verify_otp", otp_code, [channel], [recipient]}`                                                                                                                                                                                      |
| `verify_email`        | "Confirm email"                                                                 | `{action: "verify_email", verification_code}`                                                                                                                                                                                                   |
| `resend_verification` | "Resend code"                                                                   | `{action: "resend_verification"}`                                                                                                                                                                                                               |
| `enable_biometric`    | "Enable Face ID"                                                                | `{action: "enable_biometric"}` — your handler calls `LAContext.evaluatePolicy`                                                                                                                                                                  |

### Account lifecycle

| Action             | Typical button label | Payload to your handler                                                                     |
| ------------------ | -------------------- | ------------------------------------------------------------------------------------------- |
| `logout`           | "Sign out"           | `{action: "logout"}`                                                                        |
| `change_password`  | "Change password"    | `{action: "change_password", current_password, new_password}`                               |
| `set_new_password` | "Set new password"   | `{action: "set_new_password", new_password, [reset_token]}` (post-reset / first-time setup) |
| `delete_account`   | "Delete account"     | `{action: "delete_account", [confirmation_text]}`                                           |
| `update_profile`   | "Save"               | `{action: "update_profile", ...all step inputs}`                                            |

### `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 `nil` 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

```swift theme={null}
class OnboardingHandler: AppDNAOnboardingDelegate {
    func onBeforeStepAdvance(
        flowId: String,
        fromStepId: String,
        stepIndex: Int,
        stepType: String,
        responses: [String: Any],
        stepData: [String: Any]?
    ) async -> StepAdvanceResult {
        guard let action = stepData?["action"] as? String else {
            return .proceed
        }

        switch action {
        case "login":
            let email = stepData?["email"] as? String ?? ""
            let password = stepData?["password"] as? String ?? ""
            do {
                let user = try await authClient.signIn(email: email, password: password)
                return .proceedWithData(["user_id": user.id])
            } catch {
                return .block(message: "Invalid email or password.")
            }

        case "register":
            let email = stepData?["email"] as? String ?? ""
            let password = stepData?["password"] as? String ?? ""
            do {
                let user = try await authClient.register(email: email, password: password)
                return .proceedWithData(["user_id": user.id])
            } catch {
                return .block(message: "Couldn't create account — try a different email.")
            }

        case "reset_password":
            let email = stepData?["email"] as? String ?? ""
            try? await authClient.requestPasswordReset(email: email)
            // Stay on step; show inline confirmation via your own UI binding
            return .block(message: "If an account exists for \(email), we've emailed a reset link.")

        case "request_otp":
            // Fail explicitly when the SDK couldn't resolve the channel
            // (step has both phone and email inputs, or neither). Set the
            // button's action_value in the Console to disambiguate.
            guard let channel = stepData?["channel"] as? String,
                  let recipient = stepData?["recipient"] as? String else {
                return .block(message: "Couldn't determine OTP delivery channel — please contact support.")
            }
            do {
                try await otpClient.send(channel: channel, to: recipient)
                return .proceed
            } catch {
                return .block(message: "Couldn't send code — please retry.")
            }

        case "verify_otp":
            guard let code = stepData?["otp_code"] as? String,
                  let recipient = stepData?["recipient"] as? String else {
                return .block(message: "Missing code or recipient.")
            }
            do {
                try await otpClient.verify(code: code, recipient: recipient)
                return .proceed
            } catch {
                return .block(message: "Code didn't match — try again.")
            }

        case "enable_biometric":
            let context = LAContext()
            var error: NSError?
            if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) {
                let success = (try? await context.evaluatePolicy(
                    .deviceOwnerAuthenticationWithBiometrics,
                    localizedReason: "Enable Face ID for faster sign-in",
                )) ?? false
                return .proceedWithData(["biometric_enabled": success])
            }
            return .proceed

        default:
            return .proceed
        }
    }
}
```

<Info>
  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.
</Info>

### Custom View Registration

Register your own SwiftUI views to be rendered inside onboarding steps wherever a `custom_view` block appears:

```swift theme={null}
AppDNA.registerCustomView("my_view") {
    AnyView(MySwiftUIView())
}

// Register before presenting the onboarding flow
AppDNA.registerCustomView("terms_acceptance") {
    AnyView(TermsAcceptanceView(onAccept: { accepted in
        // Handle acceptance
    }))
}
```

The `id` must match the custom view identifier configured in the Console for the `custom_view` block.

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

| Type              | Description                              | Example Use Case           |
| ----------------- | ---------------------------------------- | -------------------------- |
| `text`            | Single-line text input                   | Name, username             |
| `textarea`        | Multi-line text input                    | Bio, notes                 |
| `number`          | Numeric input with stepper               | Age, quantity              |
| `email`           | Email input with validation              | Email address              |
| `phone`           | Phone number input                       | Contact number             |
| `date`            | Date picker                              | Birthday, start date       |
| `time`            | Time picker                              | Preferred time             |
| `datetime`        | Combined date and time picker            | Appointment scheduling     |
| `select`          | Dropdown or scrollable picker            | Country, category          |
| `slider`          | Numeric slider with min/max              | Budget, intensity level    |
| `toggle`          | On/off switch                            | Opt-in preferences         |
| `stepper`         | Increment/decrement counter              | Number of items            |
| `segmented`       | Segmented control for few options        | Gender, frequency          |
| `password`        | Secure text input with visibility toggle | Password, PIN              |
| `rating`          | Star rating input                        | Satisfaction, preference   |
| `range_slider`    | Dual-handle range slider                 | Price range, age range     |
| `image_picker`    | Photo picker from library or camera      | Profile photo, document    |
| `color`           | Color picker or preset swatches          | Theme preference, branding |
| `url`             | URL input with validation                | Website, portfolio link    |
| `multiline_chips` | Tag-style multi-select chips             | Interests, skills          |
| `signature`       | Freehand signature drawing pad           | Agreement, consent         |
| `location`        | Autocomplete location search             | City, address, country     |

### 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` dictionary passed to `onOnboardingCompleted`, keyed by step ID. Each step's value is a dictionary of field ID to field value.

```swift theme={null}
func onOnboardingCompleted(flowId: String, responses: [String: Any]) {
    if let formData = responses["profile_step"] as? [String: Any] {
        let name = formData["full_name"] as? String
        let age = formData["age"] as? Int
        let email = formData["email"] as? String
        // Use collected data to personalize the experience
    }
}
```

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

Each location selection contains:

| Field               | Type      | Example                               |
| ------------------- | --------- | ------------------------------------- |
| `formatted_address` | `String`  | `"New York, New York, United States"` |
| `city`              | `String`  | `"New York"`                          |
| `state`             | `String`  | `"New York"`                          |
| `state_code`        | `String`  | `"NY"`                                |
| `country`           | `String`  | `"United States"`                     |
| `country_code`      | `String`  | `"US"`                                |
| `latitude`          | `Double`  | `40.7128`                             |
| `longitude`         | `Double`  | `-74.0060`                            |
| `timezone`          | `String`  | `"America/New_York"`                  |
| `timezone_offset`   | `Int`     | `-300` (minutes from UTC)             |
| `postal_code`       | `String?` | `null`                                |

#### Accessing Location Data

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

```swift theme={null}
if let location = AppDNA.getLocationData(fieldId: "user_location") {
    print("City: \(location.city)")           // "New York"
    print("Country: \(location.country_code)") // "US"
    print("Timezone: \(location.timezone)")    // "America/New_York"
    print("Coords: \(location.latitude), \(location.longitude)")
}
```

#### Template Engine

Location data is accessible in dynamic content templates:

```
Welcome from {{onboarding.location_step.user_location.city}}!
Your timezone: {{onboarding.location_step.user_location.timezone}}
```

#### Configuration in Console

In the onboarding flow editor, add a `location` field to a form step and configure:

| Option         | Description                                            | Default |
| -------------- | ------------------------------------------------------ | ------- |
| Location Type  | Filter results: `city`, `address`, `region`, `country` | `city`  |
| Bias Country   | ISO country code to prioritize results (e.g., `US`)    | None    |
| Language       | Language for results (e.g., `en`, `fr`)                | `en`    |
| Min Characters | Characters required before search triggers             | `2`     |

<Info>
  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**.
</Info>

## 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 `POST`s 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)

```json theme={null}
{
  "event": "chat_message",
  "flow_id": "onboarding_v1",
  "step_id": "chat_intro",
  "app_id": "app_abc123",
  "user_id": "user_xyz",
  "conversation": {
    "turn": 2,
    "user_message": "I've been having vivid dreams lately",
    "max_turns": 5,
    "remaining_turns": 3,
    "messages": [
      { "role": "ai",   "content": "Hi, what brings you here?",       "id": "msg_a0", "timestamp": "2026-04-15T10:00:00Z" },
      { "role": "user", "content": "I've been having vivid dreams...", "id": "msg_u1", "timestamp": "2026-04-15T10:00:12Z" }
    ]
  },
  "context": { "threadId": "thread_UXJWTKpSvBRWpGEhHLXqnA6O" },
  "rating": null
}
```

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)

```json theme={null}
{
  "action": "reply",
  "messages": [
    { "content": "Tell me more about the most recent one." }
  ]
}
```

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

<Warning>
  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": "..." }]`.
</Warning>

#### Full response schema

| Field                 | Type                                             | Description                                                                                                                                                                                                                                                                               |
| --------------------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `action`              | `"reply"` \| `"reply_and_complete"` \| `"error"` | Default `"reply"`. `"reply_and_complete"` renders the reply then ends the chat.                                                                                                                                                                                                           |
| `messages`            | `Array<{ content, media?, delay_ms? }>`          | Messages to render. Empty array = silent turn.                                                                                                                                                                                                                                            |
| `messages[].content`  | `string`                                         | The bubble text.                                                                                                                                                                                                                                                                          |
| `messages[].media`    | `{ type, url, alt_text? }`                       | Optional media — `type` is `"image"`, `"lottie"`, or `"link"`.                                                                                                                                                                                                                            |
| `messages[].delay_ms` | `number`                                         | Delay before the bubble appears (typing simulation).                                                                                                                                                                                                                                      |
| `quick_replies`       | `Array<{ id, text }>`                            | Buttons rendered under the latest AI reply. Tapping one sends it as the user's next message.                                                                                                                                                                                              |
| `force_complete`      | `boolean`                                        | End the conversation after this reply.                                                                                                                                                                                                                                                    |
| `completion_message`  | `string`                                         | Final message shown when the chat completes.                                                                                                                                                                                                                                              |
| `data`                | `object`                                         | Free-form JSON. Two uses: (1) stored in the onboarding response bundle as `webhook_data` for analytics/later steps; (2) **echoed back to your webhook in `context` on every subsequent turn** — use this for `threadId`, `session_id`, or any opaque server state you need to round-trip. |

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

```javascript theme={null}
app.post('/chat', async (req, res) => {
  const { conversation } = req.body;
  const userMessage = conversation.user_message;

  const replyText = await myLLM.complete(userMessage);

  res.json({
    action: "reply",
    messages: [{ content: replyText }],
  });
});
```

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

```javascript theme={null}
app.post('/chat', async (req, res) => {
  const { conversation, context } = req.body;

  // Resume if we have a threadId; create a fresh one otherwise
  let threadId = context?.threadId;
  if (!threadId) {
    threadId = await aiService.createThread();
  }

  let replyText;
  try {
    replyText = await aiService.sendMessage(threadId, conversation.user_message);
  } catch (err) {
    // Thread expired or invalid — start fresh and retry once
    if (err.code === 'thread_expired') {
      threadId = await aiService.createThread();
      replyText = await aiService.sendMessage(threadId, conversation.user_message);
    } else {
      throw err;
    }
  }

  res.json({
    action: "reply",
    messages: [{ content: replyText }],
    data: { threadId },   // round-tripped to every subsequent call
  });
});
```

No server-side session store, no per-user cache, no history replay. The `context` round-trip IS your session storage.

<Note>
  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.
</Note>

### Console configuration

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

| Field             | Purpose                                                                   |
| ----------------- | ------------------------------------------------------------------------- |
| **Webhook URL**   | Where the SDK `POST`s each turn                                           |
| **Headers**       | Custom headers — for bearer auth, the full value must be `Bearer <token>` |
| **Timeout (ms)**  | How long to wait before showing `error_text`                              |
| **Retry count**   | Number of retries on transient network errors                             |
| **Error text**    | Shown in-chat when the webhook fails or times out                         |
| **Persona**       | Name, role, avatar — rendered in the header                               |
| **Max turns**     | Caps the conversation length                                              |
| **Auto-messages** | Pre-scripted AI messages keyed by turn number                             |
| **Quick replies** | Static buttons always shown                                               |
| **Turn actions**  | Trigger rating prompts / inject messages at specific turns                |
| **Style**         | Colors, fonts, bubble styling                                             |

### Auto-tracked events

| Event                   | When                                                                                                                                                                                                                                                           |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `chat_message_sent`     | User sends a message                                                                                                                                                                                                                                           |
| `chat_message_received` | Webhook reply is received (even if empty)                                                                                                                                                                                                                      |
| `chat_webhook_error`    | Webhook throws, times out, returns a non-2xx status, or returns invalid JSON. Includes `http_status` and a truncated `response_body` when the error is a non-2xx response, so integration bugs surface immediately instead of silently showing an empty reply. |
| `chat_rating_submitted` | User submits a rating in a turn action                                                                                                                                                                                                                         |
| `chat_completed`        | Chat reaches max turns, `force_complete: true`, or the user explicitly completes                                                                                                                                                                               |

## Async Step Hooks

The onboarding delegate supports two async hooks 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:

```swift theme={null}
func onBeforeStepAdvance(
    flowId: String,
    fromStepId: String,
    stepIndex: Int,
    stepType: String,
    responses: [String: Any],
    stepData: [String: Any]?
) async -> StepAdvanceResult {
    // Example: validate a referral code with your backend
    if stepType == "form",
       let code = (responses[fromStepId] as? [String: Any])?["referral_code"] as? String {
        let isValid = await validateReferralCode(code)
        if !isValid {
            return .block(message: "Invalid referral code. Please try again.")
        }
        return .proceedWithData(["referral_validated": true])
    }
    return .proceed
}
```

#### StepAdvanceResult

| Case                            | Description                                                                                                                                                                                         |
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `.proceed`                      | Continue to the next step normally                                                                                                                                                                  |
| `.proceedWithData(_:)`          | Continue and merge additional data into the session                                                                                                                                                 |
| `.block(message:)`              | Block advancement and show an error message (red banner) to the user                                                                                                                                |
| `.stay(message:)`               | Stay on the current step without advancing and without showing an error. Pass a non-nil `message` to render a green success banner; pass `nil` (or omit) to stay silent and let your code handle UI |
| `.skipTo(stepId:)`              | Skip to a specific step by ID                                                                                                                                                                       |
| `.skipToWithData(stepId:data:)` | Skip to a specific step and merge additional data                                                                                                                                                   |

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

```swift theme={null}
case "reset_password":
    let email = (responses[fromStepId] as? [String: Any])?["email"] as? String ?? ""
    do {
        try await myAuthBackend.sendPasswordReset(to: email)
        return .stay(message: "Reset email sent to \(email)")
    } catch {
        return .block(message: "Couldn't send reset email. Please try again.")
    }
```

### onBeforeStepRender

Called before a step is rendered. Return a `StepConfigOverride` to dynamically modify the step's content:

```swift theme={null}
func onBeforeStepRender(
    flowId: String,
    stepId: String,
    stepIndex: Int,
    stepType: String,
    responses: [String: Any]
) async -> StepConfigOverride? {
    // Pre-fill form fields based on user data
    if stepId == "profile_step" {
        return StepConfigOverride(
            fieldDefaults: [
                "email": currentUser.email,
                "name": currentUser.displayName
            ],
            title: "Welcome back, \(currentUser.firstName)!"
        )
    }
    return nil
}
```

<Info>
  Both hooks are `async` -- the SDK shows a loading indicator while waiting for your response. If either hook throws an error or times out, the SDK proceeds normally.
</Info>

## Row Direction and Distribution

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

| Property       | Options                                                                    | Description                                     |
| -------------- | -------------------------------------------------------------------------- | ----------------------------------------------- |
| `direction`    | `horizontal`, `vertical`                                                   | Axis along which child blocks are arranged      |
| `distribution` | `equal`, `fill`, `start`, `center`, `end`, `space_between`, `space_around` | How child blocks are distributed within the row |

```
// Example: Two buttons side by side, equally spaced
Row (direction: horizontal, distribution: equal)
  ├── Button "Skip"
  └── Button "Continue"
```

## Button Gradients

Buttons support gradient backgrounds configured in the Console:

| Property             | Type       | Description                                                  |
| -------------------- | ---------- | ------------------------------------------------------------ |
| `gradient_colors`    | `[String]` | Array of hex color stops (e.g., `["#FF6B6B", "#4ECDC4"]`)    |
| `gradient_direction` | `String`   | `horizontal`, `vertical`, `diagonal_tl_br`, `diagonal_tr_bl` |

Gradients override the solid `background_color` when set.

## Select Display Styles

Question steps with selectable options support three display styles:

| Style      | Description                                                          |
| ---------- | -------------------------------------------------------------------- |
| `dropdown` | Native dropdown picker. Best for long option lists (5+ items).       |
| `stacked`  | Vertically stacked option buttons. Default style.                    |
| `grid`     | Grid layout with 2 or 3 columns. Good for visual options with icons. |

The display style is configured per question step in the Console.

## Progress Bar Custom Colors

The `progress_bar` content block supports custom color configuration:

| Property        | Type       | Description                                                |
| --------------- | ---------- | ---------------------------------------------------------- |
| `fill_color`    | `String`   | Hex color for the filled portion                           |
| `track_color`   | `String`   | Hex color for the unfilled track                           |
| `fill_gradient` | `[String]` | Gradient color stops for the fill (overrides `fill_color`) |
| `corner_radius` | `Number`   | Corner radius of the progress bar                          |
| `height`        | `Number`   | Height of the progress bar in points                       |

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%).

| Property        | Type   | Default | Description                                    |
| --------------- | ------ | ------- | ---------------------------------------------- |
| `hide_progress` | `Bool` | `false` | Hides the progress indicator on this step only |

<Info>
  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`.
</Info>

## Conditional Branching

Onboarding flows support conditional branching -- the next step can change based on the user's answer. Branching is configured entirely in the Console.

**Example:**

```
Step 1: "What's your fitness goal?"
  → "Lose weight"  → Step 2a: Weight loss program
  → "Build muscle" → Step 2b: Muscle building program
  → "Stay active"  → Step 2c: General fitness
```

To configure branching:

1. In the Console, open your onboarding flow (**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` dictionary in `onOnboardingCompleted`.

## Auto-Tracked Events

The SDK automatically tracks the following onboarding-related events:

| Event                       | Triggered When                                     |
| --------------------------- | -------------------------------------------------- |
| `onboarding_flow_started`   | An onboarding flow begins                          |
| `onboarding_step_viewed`    | A step is displayed to the user                    |
| `onboarding_step_completed` | A user completes a step (e.g., answers a question) |
| `onboarding_step_skipped`   | A user skips a step                                |
| `onboarding_flow_completed` | The user completes the entire flow                 |
| `onboarding_flow_dismissed` | The user dismisses the flow before completing      |

Each event includes the `flowId`, `stepId`, and `stepIndex` where applicable.

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

<Warning>
  Flows must be published in the Console before they appear in the SDK. Draft flows are not delivered to client devices.
</Warning>

## Full Example

```swift theme={null}
import AppDNASDK

class OnboardingCoordinator: AppDNAOnboardingDelegate {
    private let rootViewController: UIViewController

    init(rootViewController: UIViewController) {
        self.rootViewController = rootViewController
        AppDNA.onboarding.setDelegate(self)
    }

    func showOnboardingIfNeeded() {
        // Pass nil to show the active flow from remote config
        let presented = AppDNA.presentOnboarding(
            flowId: nil,
            from: rootViewController,
            delegate: self
        )

        if !presented {
            // No active flow or config not loaded yet
            navigateToMainApp()
        }
    }

    // MARK: - AppDNAOnboardingDelegate

    func onOnboardingStarted(flowId: String) {
        print("Starting flow: \(flowId)")
    }

    func onOnboardingStepChanged(
        flowId: String,
        stepId: String,
        stepIndex: Int,
        totalSteps: Int
    ) {
        // Track progress
    }

    func onOnboardingCompleted(flowId: String, responses: [String: Any]) {
        // Personalize based on responses
        if let goal = responses["fitness_goal"] as? String {
            AppDNA.identify(userId: currentUserId, traits: ["fitness_goal": goal])
        }
        navigateToMainApp()
    }

    func onOnboardingDismissed(flowId: String, atStep: Int) {
        navigateToMainApp()
    }
}
```

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

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.61** 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()` 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.

```text theme={null}
☑ Skip if user already has an active subscription   ← default for new + legacy nodes
```

Existing flows authored before 1.0.61 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:

| Option                          | Stored value    | When to pick                                                                                                                                           |
| ------------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Complete flow (recommended)** | `complete_flow` | Subscribed user bypasses the paywall **and** everything downstream — reaches your home screen immediately. Default for new paywall\_trigger nodes.     |
| Use post-purchase target        | `''` (empty)    | Falls back to whatever `On purchase success` is set to — useful when the post-purchase target and the skip target should be identical.                 |
| Follow downstream edge          | `continue`      | Subscribed user continues through the rest of the graph (rare — used for chain-of-paywalls flows that intentionally show more upsells after the gate). |
| Go to: `<node>`                 | the node id     | Route directly to a specific step or end node.                                                                                                         |

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.61 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 delegate.
2. **Auto-dismisses the paywall surface** 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: []`) the paywall stays up so the user can attempt a fresh purchase or close manually.

#### Hosts that handle their own dismiss

If you already dismiss the paywall inside your own `onPaywallRestoreCompleted` delegate body (e.g., to show a "Restored — tap continue when ready" overlay), set the new `skipSDKAutoDismiss` flag inside the delegate body before returning. The SDK auto-dismiss then no-ops.

```swift theme={null}
class MyDelegate: AppDNAPaywallDelegate {
    func onPaywallRestoreCompleted(paywallId: String, productIds: [String]) {
        // Your custom UI handling — show overlay, etc.
        // No need to call dismiss() yourself; the SDK auto-dismiss path
        // handles dismissal once your synchronous body returns. If you
        // want to suppress the SDK auto-dismiss (e.g., to show your own
        // overlay), set the flag synchronously inside this method.
    }
}
```

The `dispatchedDismiss` guard inside the SDK ensures dismiss never fires twice — first caller wins. Synchronous host-side dismisses inside the delegate body always win the race.

### `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. The next paywall\_trigger gate then reflects the identified user's 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 `await AppDNA.billing.refreshEntitlementCache()` 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 StoreKit 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.

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

| Key                        | Meaning                                        |
| -------------------------- | ---------------------------------------------- |
| `<field_id>`               | value in the base unit (stable across toggles) |
| `<field_id>_unit`          | the base unit id                               |
| `<field_id>_display_unit`  | the unit the user selected                     |
| `<field_id>_display_value` | value shown in the selected unit               |

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.
