> ## 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 React Native to preview draft experiences and route push notification taps.

The Userpilot React Native module 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.

Setting it up is two steps: register the scheme on each platform so the OS routes matching links to your app, then forward the incoming URL to the Userpilot SDK from JavaScript.

<Note>
  Using Expo? Follow [Custom URL Scheme with Expo](./expo_custom_scheme) instead. Expo registers the scheme from `app.json` and delivers links through `expo-linking`, so the setup differs from the steps below.
</Note>

***

## Prerequisites

Your app must be configured to support [Linking](https://reactnative.dev/docs/linking). Follow the React Native guide for [Enabling Deep Links](https://reactnative.dev/docs/linking#enabling-deep-links) on both Android and iOS before continuing.

***

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

The quickest way to register it on both platforms is `uri-scheme`:

```bash theme={null}
npx uri-scheme add userpilot-APP_TOKEN --ios
npx uri-scheme add userpilot-APP_TOKEN --android
```

After running the Android command, open `android/app/src/main/AndroidManifest.xml` and set `android:host="sdk"` on the Userpilot scheme's `<data>` element in the main Activity's `VIEW` intent filter:

```xml theme={null}
<data android:scheme="userpilot-APP_TOKEN" android:host="sdk" />
```

Userpilot validates that incoming preview and push-notification URLs use the `sdk` host.

### Register it manually

To register the scheme by hand instead, apply the following changes.

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

**iOS** — in `AppDelegate.swift`, hand the URL to React Native's linking manager so it reaches your JavaScript listener:

```swift theme={null}
@main
class AppDelegate: RCTAppDelegate {
  override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
    ...
  }

  override func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
    return RCTLinkingManager.application(app, open: url, options: options)
  }
}
```

**Android** — in `AndroidManifest.xml`, on the main Activity. Set `android:launchMode="singleTop"` so an incoming link is delivered to the running Activity rather than creating a second instance of it:

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

***

## Handle the custom URL scheme

Pass every incoming URL to `Userpilot.didHandleURL(url)`. It resolves to `true` when the SDK recognized and handled the link, and `false` when your app should handle it. The function is asynchronous, so `await` it or use the returned promise.

React Native delivers links through two separate paths, and you need both:

| **Path** | **Covers** |
| - | - |
| `Linking.addEventListener('url', …)` | A link that arrives while your app is already running |
| `Linking.getInitialURL()` | The link that launched your app from a cold start |

Handling only the event listener silently drops every link that starts the app, which includes scanning the Builder's QR code while your app is closed.

```jsx theme={null}
import { useEffect } from 'react';
import { Linking } from 'react-native';
import * as Userpilot from '@userpilot/react-native';

async function handleURL(url) {
  if (!url) return;

  const userpilotDidHandleURL = await Userpilot.didHandleURL(url);

  if (!userpilotDidHandleURL) {
    // Handle a non-Userpilot URL
  }
}

useEffect(() => {
  // Links that arrive while the app is running
  const subscription = Linking.addEventListener('url', ({ url }) => handleURL(url));

  // The link that launched the app
  Linking.getInitialURL().then(handleURL);

  return () => subscription.remove();
}, []);
```

<Warning>
  **Set up the SDK before you consume the launch link**

  `didHandleURL` returns `false` when the SDK hasn't been set up yet, which sends the link to your own routing instead of Userpilot. Call `Userpilot.setup(...)` before you resolve `getInitialURL()`.
</Warning>

***

## 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>
  `didHandleURL` is the single entry point on both platforms, and it covers push notification taps too. On Android the SDK posts each notification with a matching deep link, so a tap arrives at this same handler. On iOS notification taps are handled natively by the SDK and never reach your URL listener. The `didHandleIntent` method was removed in `@userpilot/react-native` 1.2.0, so don't reach for it.
</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 `didHandleURL` handling 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. You can also use `npx uri-scheme open "userpilot-nx-12345678://sdk/experience_preview/12345" --ios` if you prefer to stay in the JavaScript toolchain.

***

## 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, so your listener is not called at all. Verify the registration with the commands above.
  </Accordion>

  <Accordion title="It works when the app is open, but not when it's closed">
    You're handling `Linking.addEventListener` but not `Linking.getInitialURL()`. The event listener never fires for the link that launched the app, so a cold start needs the explicit `getInitialURL()` call shown above.
  </Accordion>

  <Accordion title="`didHandleURL` always returns false">
    Two common causes. First, the SDK wasn't set up yet when you called it, so call `Userpilot.setup(...)` first. Second, `didHandleURL` returns a promise: without `await`, you're testing the promise object rather than its result, and a promise is always truthy.
  </Accordion>

  <Accordion title="Tapping an Android push notification does nothing">
    Notification taps travel over this same URL scheme on Android, so they need the intent filter and the `didHandleURL` forwarding described above. Confirm the host in your filter is exactly `sdk`.
  </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>
