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

The Userpilot Capacitor 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.

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 your web layer.

***

## Prerequisites

Your app must be configured to handle [deep links](https://capacitorjs.com/docs/guides/deep-links) on both Android and iOS.

***

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

For iOS, `uri-scheme` writes the `Info.plist` entry for you:

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

For Android, add the intent filter to `android/app/src/main/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=".MainActivity"
    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 the filter. The native Userpilot SDK validates the URL host before handling a preview or notification link.

Run `npx cap sync` after editing native configuration.

***

## Handle the custom URL scheme

Listen for the `appUrlOpen` event from `@capacitor/app` and pass each URL to `Userpilot.didHandleURL`.

<Warning>
  **`didHandleURL` is asynchronous and resolves to an object**

  The signature is `didHandleURL({ url }): Promise<{ handled: boolean }>`. You must `await` it and read the `handled` property. Testing the returned value directly always looks truthy, because an unawaited promise is an object, so your own link handling never runs.
</Warning>

```tsx theme={null}
import React, { useEffect } from 'react';
import { App, URLOpenListenerEvent } from '@capacitor/app';
import { Userpilot } from '@userpilot/capacitor';

const AppUrlListener: React.FC<any> = () => {
  useEffect(() => {
    const listener = App.addListener('appUrlOpen', async (event: URLOpenListenerEvent) => {
      const { handled } = await Userpilot.didHandleURL({ url: event.url });

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

    return () => {
      listener.then((handle) => handle.remove());
    };
  }, []);

  return null;
};

export default AppUrlListener;
```

Mount the listener once, near the root of your app, so it's active before any link arrives:

```tsx theme={null}
<IonApp>
  <IonReactRouter>
    <AppUrlListener />
    ...
  </IonReactRouter>
</IonApp>
```

For more on link handling in Ionic, see the Ionic documentation on [deep links](https://ionicframework.com/docs/native/deeplinks).

***

## 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 the `appUrlOpen` event, 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 `appUrlOpen` 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 listener 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. Then confirm you ran `npx cap sync` and rebuilt, since a scheme is native configuration and won't appear in an older build. Verify with the commands above.
  </Accordion>

  <Accordion title="My own link handling never runs">
    You're most likely not awaiting `didHandleURL`. It returns a promise that resolves to `{ handled: boolean }`, so `if (!result)` is always false against the promise object. Destructure `handled` from an awaited call, as shown above.
  </Accordion>

  <Accordion title="The app opens but the experience doesn't appear">
    The OS routed the link, so registration is correct. Check that `AppUrlListener` is mounted at the root of your app rather than inside a page that may not be rendered when the link arrives, and confirm the SDK is initialized with the same token that generated the QR code.
  </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 `appUrlOpen` 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>
