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

# Custom URL Scheme

> Register and handle the Userpilot custom URL scheme in Flutter to preview draft experiences and route push notification taps.

The Userpilot Flutter plugin supports a custom URL scheme that lets you preview experiences on a real device before publishing them, and that carries push notification taps back into your app.

Flutter has no built-in bridge for delivering a raw URL to Dart, so the setup has three parts: register the scheme with each OS, forward the URL from your platform code into Dart, then pass it to the SDK. The example app in the plugin repository implements one working version of this using its own method and event channels, which is the approach shown here.

<Warning>
  **Pass the whole URL, not just the path**

  The SDK matches on the scheme and host, so it needs the complete URL. Forwarding only the path segment means no link is ever recognized.
</Warning>

***

## Prerequisites

If your app already uses Flutter [deep linking](https://docs.flutter.dev/development/ui/navigation/deep-linking) with the `Router` widget, disable Flutter's automatic link handling so that you receive every link and decide where it goes.

**iOS** — in `Info.plist`:

```xml theme={null}
<key>FlutterDeepLinkingEnabled</key>
<false/>
```

**Android** — in `AndroidManifest.xml`, on the main Activity:

```xml theme={null}
<meta-data android:name="flutter_deeplinking_enabled" android:value="false" />
```

You then process all links yourself and pass your app's own links through to the `Router` system, as shown in [Handle the custom URL scheme](#handle-the-custom-url-scheme).

***

## Register the custom URL scheme

Replace `APP_TOKEN` with your app's Userpilot token, which you can obtain from your [Environments Page](https://run.userpilot.io/environment). For example, if your token is `NX-12345678`, your scheme value is `userpilot-nx-12345678`.

**iOS** — in `Info.plist`:

```xml theme={null}
<key>CFBundleURLTypes</key>
<array>
    <dict>
        <key>CFBundleTypeRole</key>
        <string>Editor</string>
        <key>CFBundleURLName</key>
        <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
        <key>CFBundleURLSchemes</key>
        <array>
            <string>userpilot-APP_TOKEN</string>
        </array>
    </dict>
</array>
```

**Android** — in `AndroidManifest.xml`, on the main Activity:

```xml theme={null}
<activity
    android:name="..."
    android:exported="true"
    android:launchMode="singleTop">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
    <intent-filter>
        <data android:scheme="userpilot-APP_TOKEN" android:host="sdk" />
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
    </intent-filter>
</activity>
```

Keep `android:host="sdk"` in this filter. Userpilot validates that incoming preview and push-notification URLs use the `sdk` host.

***

## Forward links from iOS

Which iOS setup you use depends on your Flutter version. The recommended one uses the `UIScene` lifecycle and needs **Flutter 3.38.0 or later**, because it relies on `FlutterSceneDelegate` and `FlutterImplicitEngineDelegate`, which were introduced in that release. Below 3.38.0, use the app-delegate setup.

This affects only the integration code in your own app. The plugin itself works on any Flutter version its `pubspec.yaml` supports and requires no scene adoption.

<Tabs>
  <Tab title="Flutter 3.38.0 and later">
    First, register your scene delegate in `Info.plist`:

    ```xml theme={null}
    <key>UIApplicationSceneManifest</key>
    <dict>
        <key>UIApplicationSupportsMultipleScenes</key>
        <false/>
        <key>UISceneConfigurations</key>
        <dict>
            <key>UIWindowSceneSessionRoleApplication</key>
            <array>
                <dict>
                    <key>UISceneClassName</key>
                    <string>UIWindowScene</string>
                    <key>UISceneConfigurationName</key>
                    <string>flutter</string>
                    <key>UISceneDelegateClassName</key>
                    <string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
                    <key>UISceneStoryboardFile</key>
                    <string>Main</string>
                </dict>
            </array>
        </dict>
    </dict>
    ```

    Under the scene lifecycle, your window and its `FlutterViewController` are created *after* `application(_:didFinishLaunchingWithOptions:)` returns. That shapes the code below in two ways: the launch URL is delivered to the scene rather than in `launchOptions`, and there is no binary messenger to build channels on until the Flutter engine exists. So the scene delegate captures the launch URL, and plugin registration and channel setup happen in `didInitializeImplicitFlutterEngine`, which Flutter calls once the engine is ready.

    `SceneDelegate.swift`:

    ```swift theme={null}
    import Flutter
    import UIKit

    class SceneDelegate: FlutterSceneDelegate {

        override func scene(
            _ scene: UIScene,
            willConnectTo session: UISceneSession,
            options connectionOptions: UIScene.ConnectionOptions
        ) {
            (UIApplication.shared.delegate as? AppDelegate)?.captureInitialLinkIfNeeded(from: connectionOptions)
            super.scene(scene, willConnectTo: session, options: connectionOptions)
        }

        override func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
            let appDelegate = UIApplication.shared.delegate as? AppDelegate
            for context in URLContexts {
                _ = appDelegate?.handleOpen(url: context.url)
            }
            super.scene(scene, openURLContexts: URLContexts)
        }
    }
    ```

    `AppDelegate.swift`:

    ```swift theme={null}
    import Flutter
    import UIKit
    import Userpilot

    @main
    @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {

        private var methodChannel: FlutterMethodChannel?
        private var eventChannel: FlutterEventChannel?
        private let linkStreamHandler = LinkStreamHandler()
        /// Populated from `UIScene.ConnectionOptions` on cold start; launch options are not
        /// available under the scene lifecycle.
        private(set) var pendingInitialLink: String?

        override func application(
            _ application: UIApplication,
            didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
        ) -> Bool {

            // Automatically configure for push notifications
            Userpilot.enableAutomaticPushConfig()

            return super.application(application, didFinishLaunchingWithOptions: launchOptions)
        }

        func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
            GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)

            let messenger = engineBridge.applicationRegistrar.messenger()
            methodChannel = FlutterMethodChannel(
                name: "com.userpilot.samples.flutter/channel",
                binaryMessenger: messenger
            )
            eventChannel = FlutterEventChannel(
                name: "com.userpilot.samples.flutter/events",
                binaryMessenger: messenger
            )

            methodChannel?.setMethodCallHandler({ [weak self] (call: FlutterMethodCall, result: FlutterResult) in
                guard call.method == "initialLink" else {
                    result(FlutterMethodNotImplemented)
                    return
                }

                result(self?.pendingInitialLink)
            })

            eventChannel?.setStreamHandler(linkStreamHandler)
        }

        func captureInitialLinkIfNeeded(from connectionOptions: UIScene.ConnectionOptions) {
            guard pendingInitialLink == nil, let url = connectionOptions.urlContexts.first?.url else { return }
            pendingInitialLink = url.absoluteString
        }

        func handleOpen(url: URL) -> Bool {
            eventChannel?.setStreamHandler(linkStreamHandler)
            return linkStreamHandler.handleLink(url.absoluteString)
        }
    }
    ```
  </Tab>

  <Tab title="Earlier Flutter versions">
    Use this setup if your app is on a Flutter version below 3.38.0.

    <Warning>
      Do **not** add the `UIApplicationSceneManifest` entry from the other tab alongside this code. Once your app adopts the scene lifecycle, the launch URL goes to the scene instead of `launchOptions`, `application(_:open:options:)` stops being called, and `window` is nil while `didFinishLaunchingWithOptions` runs, so the force-cast below crashes on launch.
    </Warning>

    `AppDelegate.swift`:

    ```swift theme={null}
    import Flutter
    import UIKit
    import Userpilot

    @main
    @objc class AppDelegate: FlutterAppDelegate {

        private var methodChannel: FlutterMethodChannel?
        private var eventChannel: FlutterEventChannel?
        private let linkStreamHandler = LinkStreamHandler()

        override func application(
            _ application: UIApplication,
            didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
        ) -> Bool {

            // Automatically configure for push notifications
            Userpilot.enableAutomaticPushConfig()

            // Handling deeplink. `.url` holds a URL, not a String, so it has to be
            // bridged through `absoluteString`; casting straight to String always
            // yields nil and silently drops cold-start deep links.
            let initialLink = (launchOptions?[.url] as? URL)?.absoluteString

            let controller = window?.rootViewController as! FlutterViewController
            methodChannel = FlutterMethodChannel(name: "com.userpilot.samples.flutter/channel", binaryMessenger: controller as! FlutterBinaryMessenger)
            eventChannel = FlutterEventChannel(name: "com.userpilot.samples.flutter/events", binaryMessenger: controller as! FlutterBinaryMessenger)

            methodChannel?.setMethodCallHandler({ (call: FlutterMethodCall, result: FlutterResult) in
                guard call.method == "initialLink" else {
                    result(FlutterMethodNotImplemented)
                    return
                }

                result(initialLink)
            })

            GeneratedPluginRegistrant.register(with: self)
            eventChannel?.setStreamHandler(linkStreamHandler)
            return super.application(application, didFinishLaunchingWithOptions: launchOptions)
        }

        override func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
            eventChannel?.setStreamHandler(linkStreamHandler)
            return linkStreamHandler.handleLink(url.absoluteString)
        }
    }
    ```
  </Tab>
</Tabs>

Both setups use the same `LinkStreamHandler`:

```swift theme={null}
class LinkStreamHandler: NSObject, FlutterStreamHandler {

    var eventSink: FlutterEventSink?

    // links will be added to this queue until the sink is ready to process them
    var queuedLinks = [String]()

    func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
        self.eventSink = events
        queuedLinks.forEach({ events($0) })
        queuedLinks.removeAll()
        return nil
    }

    func onCancel(withArguments arguments: Any?) -> FlutterError? {
        self.eventSink = nil
        return nil
    }

    func handleLink(_ link: String) -> Bool {
        guard let eventSink = eventSink else {
            queuedLinks.append(link)
            return false
        }
        eventSink(link)
        return true
    }
}
```

The `queuedLinks` array matters for cold starts: a link can arrive before your Dart code has subscribed to the event channel. Holding it until `onListen` runs means the link is delivered once Dart is listening, rather than dropped.

### Migrating to the scene lifecycle

Earlier versions of this guide built both channels inside `application(_:didFinishLaunchingWithOptions:)` and read the launch URL from `launchOptions[.url]`. Neither works once your app adopts the scene lifecycle. To migrate:

1. Add the `UIApplicationSceneManifest` entry shown above.
2. Add `SceneDelegate.swift`, and add it to your Xcode target.
3. Move plugin registration and channel setup out of `didFinishLaunchingWithOptions` and into `didInitializeImplicitFlutterEngine`.
4. Delete your `application(_:open:options:)` override. Warm links now arrive through `scene(_:openURLContexts:)`.
5. Leave push notification handling in the `AppDelegate`. `didRegisterForRemoteNotificationsWithDeviceToken` and the notification-response callbacks still fire there.

***

## Forward links from Android

`MainActivity.kt`:

```kotlin theme={null}
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Bundle
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugins.GeneratedPluginRegistrant

class MainActivity : FlutterFragmentActivity() {

    companion object {
        const val CHANNEL = "com.userpilot.samples.flutter/channel"
        const val EVENTS = "com.userpilot.samples.flutter/events"
    }
    private var startString: String? = null
    private var linksReceiver: BroadcastReceiver? = null

    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        GeneratedPluginRegistrant.registerWith(flutterEngine)

        MethodChannel(flutterEngine.dartExecutor, CHANNEL).setMethodCallHandler { call, result ->
            if (call.method == "initialLink") {
                if (startString != null) {
                    result.success(startString)
                }
            }
        }

        EventChannel(flutterEngine.dartExecutor, EVENTS).setStreamHandler(
            object : EventChannel.StreamHandler {
                override fun onListen(args: Any?, events: EventChannel.EventSink) {
                    linksReceiver = createChangeReceiver(events)
                }

                override fun onCancel(args: Any?) {
                    linksReceiver = null
                }
            }
        )
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        startString = intent.data?.toString()
    }

    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        if (intent.action === Intent.ACTION_VIEW) {
            linksReceiver?.onReceive(this.applicationContext, intent)
        }
    }

    fun createChangeReceiver(events: EventChannel.EventSink): BroadcastReceiver? {
        return object : BroadcastReceiver() {
            // NOTE: assuming intent.getAction() is Intent.ACTION_VIEW
            override fun onReceive(context: Context, intent: Intent) {
                val dataString = intent.dataString ?:
                events.error("UNAVAILABLE", "Link unavailable", null)
                events.success(dataString)
            }
        }
    }
}
```

`onCreate` captures the launch link and `onNewIntent` forwards links that arrive while the app runs, which mirrors the two iOS entry points.

***

## Handle the custom URL scheme

In Dart, pass each incoming `Uri` to `Userpilot.didHandleURL`. It returns `true` when the SDK recognized and processed the link, and `false` when your app should route it, at which point you can hand the `.path` to your own `RouteInformationParser`.

<Warning>
  **Initialize the SDK before you consume the launch link**

  The platform side holds the launch URL until your Dart code asks for it, so awaiting `Userpilot.initialize` first means the SDK can act on the link. `didHandleURL` returns `false` while the SDK is uninitialized, which sends the preview link to your own routing instead.
</Warning>

```dart theme={null}
class _UserpilotAppState extends State<UserpilotApp> {

  // Event Channel creation
  // used for listening for deep links from platform code
  static const stream = EventChannel('com.userpilot.samples.flutter/events');

  // Method channel creation
  // used to check for initial deep link when launching app, from platform
  static const platform = MethodChannel('com.userpilot.samples.flutter/channel');

  bool _initialURILinkHandled = false;

  @override
  void initState() {
    super.initState();
    _bootstrapUserpilot();

    // Checking broadcast stream, if deep link was clicked in opened application
    stream.receiveBroadcastStream().listen((d) => _onRedirected(d));
  }

  // Initialize the SDK before consuming the launch deep link. The launch link is
  // held by the platform side until Dart asks for it, so awaiting initialization
  // first guarantees the SDK can act on it.
  Future<void> _bootstrapUserpilot() async {
    await _initializeUserpilot();

    // Checking application start by deep link
    await _startUri().then(_onRedirected);
  }

  // Detect if app was launched from a deeplink
  Future<String?> _startUri() async {
    // guard against processing initial link more than once
    if (!_initialURILinkHandled) {
      _initialURILinkHandled = true;
      return platform.invokeMethod('initialLink');
    }
    return null;
  }

  // Handle any deep link sent to the app
  Future<void> _onRedirected(String? url) async {
    if (!mounted || url == null) return;
    var uri = Uri.parse(url);

    // Pass along to Userpilot to potentially handle
    bool handled = await Userpilot.didHandleURL(uri);
    if (handled) return;

    // Otherwise, process the link as a normal app route
  }
}
```

For more on navigation and routing in Flutter, see the Flutter [navigation and routing](https://github.com/flutter/samples/tree/main/navigation_and_routing) example application.

***

## Supported links

The SDK only claims a URL when the scheme matches your token and the host is `sdk`. Every other URL is passed back to your app.

| **Link** | **Purpose** |
| - | - |
| `userpilot-{token}://sdk/experience_preview/{experience_id}` | Previews a draft Flow or Survey. This is the link behind the Builder's QR code. |
| `userpilot-{token}://sdk/notification` | Routes an Android push notification tap to the SDK. Query parameters carry the notification payload. |

<Note>
  On iOS, notification taps are handled natively by the SDK and never reach your link handling, so follow [Push Notifications](./push-notifications) for that setup.
</Note>

***

## Preview an experience with a QR code

Userpilot can render a draft experience on your device before you publish it, so you can check layout, copy, and behavior on a real screen. Preview is supported for **Flows** and **Surveys**.

The preview is delivered over the custom URL scheme. It works once your app is installed on the device and the scheme is registered and handled as described above.

<Steps>
  <Step title="Open the experience">
    Open a Flow or Survey in the Experience Builder and enter **Edit Mode**.
  </Step>

  <Step title="Start the preview">
    Select **Preview** in the top-right corner of the builder. A popup with a **QR code** appears.
  </Step>

  <Step title="Scan the QR code">
    Scan the code with your device camera. Your app opens and the experience appears.
  </Step>
</Steps>

Points to keep in mind:

* You don't need to apply or save your changes before previewing them.
* The experience doesn't need to be live.
* A preview bypasses the experience's targeting and frequency settings, so it appears even when the current user wouldn't normally qualify for it.
* Once you close the experience, the app resumes normal behavior.

***

## Test push notifications

You can send a test push to a specific user from the Userpilot dashboard and verify delivery and tap handling on a real device without sending anything to your wider audience. The notification doesn't need to be live, so you can test it while it's still a draft.

* Test pushes work in both the staging and production environments.
* Test pushes are **not** counted as real sends, and they're excluded from analytics.
* Test pushes bypass the notification's targeting and frequency settings.

For the dashboard steps, see [Mobile push notifications](/in-app-engagement/mobile-content/mobile-push-notifications).

Because a notification tap arrives as a deep link on Android, a test push also exercises your link forwarding there. If the notification appears but tapping it does nothing, the scheme registration is the first thing to check.

***

## Test a deep link from the command line

Open a link directly to confirm your scheme is registered and your handler runs, without going through the Builder:

```bash theme={null}
# Android
adb shell am start -a android.intent.action.VIEW \
  -d "userpilot-nx-12345678://sdk/experience_preview/12345"

# iOS Simulator
xcrun simctl openurl booted "userpilot-nx-12345678://sdk/experience_preview/12345"
```

Replace the scheme with your own token and `12345` with a real experience ID.

***

## Troubleshooting

<AccordionGroup>
  <Accordion title="Nothing happens when I scan the QR code">
    Confirm the registered scheme matches your token exactly, lowercased. A mismatched scheme means the OS never routes the link to your app. Verify the registration with the commands above.
  </Accordion>

  <Accordion title="The app crashes on launch after I added the scene manifest">
    You have the `UIApplicationSceneManifest` entry but still use the app-delegate code. Under the scene lifecycle, `window` is nil while `didFinishLaunchingWithOptions` runs, so `window?.rootViewController as! FlutterViewController` traps. Move to the Flutter 3.38.0 setup, or remove the manifest entry.
  </Accordion>

  <Accordion title="Links work when the app is running but not from a cold start">
    Check three things. The platform side must capture the launch link (`captureInitialLinkIfNeeded` under scenes, `launchOptions[.url]` otherwise, `intent.data` on Android). `LinkStreamHandler` must queue links until Dart subscribes. And your Dart code must actually request the launch link through the `initialLink` method channel.
  </Accordion>

  <Accordion title="`didHandleURL` returns false for a valid preview link">
    Most often the SDK was not initialized yet. Await `Userpilot.initialize` before you consume the launch link. Also confirm you're passing the complete URL, including scheme and host, rather than just `uri.path`.
  </Accordion>

  <Accordion title="My Router opens a blank page instead of the experience">
    Flutter's automatic deep link handling is intercepting the link. Set `FlutterDeepLinkingEnabled` to `false` on iOS and `flutter_deeplinking_enabled` to `false` on Android, as described in the prerequisites.
  </Accordion>

  <Accordion title="Preview works in production but not in staging">
    Each environment has its own token, and the scheme follows the token. Confirm the staging build registers the scheme for the token it initializes the SDK with.
  </Accordion>
</AccordionGroup>

<Frame>
  [**For any questions or concerns please reach out to support@userpilot.com**](mailto:support@userpilot.com)
</Frame>
