> For the complete documentation index, see [llms.txt](https://docs.amply.tools/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.amply.tools/developer-guide/handling-deeplinks.md).

# Handling deeplinks

When a campaign fires, Amply tells your app where to go — a screen, a sheet, a URL — by sending a **deeplink**. Your app decides what that URL means and whether it was handled.

A PM should read this as: "when a campaign targets this user, the SDK hands the app a URL with some metadata, and the app navigates accordingly. The developer wires that URL into their app's navigation."

**Use this when** you run campaigns that route users to screens, external URLs, or in-app surfaces. **Don't use this when** a campaign only needs to record an event or set a property — no deeplink is involved in those cases.

## What the SDK delivers

When a campaign matches the current user and fires, the SDK invokes your registered listener with:

* **`url`** — the target URL chosen by the campaign (`yourapp://promo/annual-upgrade`, `https://example.com/help`, or a custom scheme you own)
* **`info`** — metadata about the campaign behind this deeplink: `campaignId`, `campaignName`, the triggering event and its parameters, and any content the campaign carries. To credit purchases to campaigns, see [Purchases from campaigns in your analytics](/recipes/purchases-from-campaigns-in-analytics.md).
* **`consumed`** (React Native only) — whether another listener in the chain already handled this URL

Your listener returns `true` if it handled the URL, `false` otherwise. Returning `true` lets the SDK know the deeplink was acted on.

### Values filled in by the SDK (0.13.0+)

A campaign link may contain tokens such as `{@campaign.id}`, `{@custom.pet_name}`, `{@device.platform}` or `{@triggeredEvent.params.screen}`. The SDK replaces them on the device before calling your listener, so `url` — and the `url` entry in `info` — already carry the values:

```
campaign link:  yourapp://log-weight?source=amply_{@campaign.id}&pet={@custom.pet_name}
listener gets:  yourapp://log-weight?source=amply_3f9c…&pet=Rex%20Jr
```

* Values are **percent-encoded** (UTF-8), so each stays a single query value or path segment. Decode them the way your router already does.
* A value this device doesn't have arrives **empty** (`pet=`).
* Links opened by an *Open deep link* button in a mini-app campaign are filled in the same way and reach the same listener.
* Devices on an SDK older than 0.13.0 do not receive such a campaign. (Builds on SDK 0.5.4 or older report a wrong version and are not filtered out — upgrade them.)

## Registering a listener

{% tabs %}
{% tab title="iOS (Swift)" %}

```swift
import AmplySDK

class AppDeepLinkHandler: DeepLinkListener {
    func onDeepLink(url: String, info: [String: Any]) -> Bool {
        print("Amply deeplink: \(url) info: \(info)")

        if url.hasPrefix("yourapp://promo/") {
            // Navigate to promo screen
            Router.shared.showPromo(url: url)
            return true
        }

        // Let another handler deal with it
        return false
    }
}

// Somewhere during app startup, after SDK init:
let handler = AppDeepLinkHandler()
let deepLinkToken = amply.registerDeepLinkListener(listener: handler)
```

Hold a strong reference to your listener from your own side so it stays alive for the lifetime of the app, and keep the returned token - `removeDeepLinkListener(token:)` takes the token, not the listener. A listener that lives for the whole app never needs detaching; one owned by a single screen does.
{% endtab %}

{% tab title="Android (Kotlin)" %}

```kotlin
import tools.amply.sdk.actions.DeepLinkListener

class AppDeepLinkHandler : DeepLinkListener {
    override fun onDeepLink(url: String, info: Map<String, Any>): Boolean {
        Log.d("Amply", "deeplink: $url info: $info")

        if (url.startsWith("yourapp://promo/")) {
            Router.showPromo(url)
            return true
        }

        return false
    }
}

// During Application.onCreate(), after amply init:
val handler = AppDeepLinkHandler()
val deepLinkToken = amply.registerDeepLinkListener(handler)
```

{% endtab %}

{% tab title="React Native (TS)" %}

```ts
import Amply from '@amplytools/react-native-amply-sdk';
import { useEffect } from 'react';

export function useAmplyDeepLinks(onPromo: (url: string) => void) {
  useEffect(() => {
    let unsubscribe: (() => void) | undefined;
    let unmounted = false;

    Amply.addDeepLinkListener(event => {
      console.log('Amply deeplink:', event.url, event.info, 'consumed=', event.consumed);

      if (event.url.startsWith('yourapp://promo/')) {
        onPromo(event.url);
      }
    })
      .then(unsub => {
        if (unmounted) unsub();
        else unsubscribe = unsub;
      });

    return () => {
      unmounted = true;
      unsubscribe?.();
    };
  }, [onPromo]);
}
```

`addDeepLinkListener` returns a `Promise<() => void>`. Await it to get the unsubscribe function, and call it on unmount.
{% endtab %}
{% endtabs %}

## Return value (iOS + Android)

`onDeepLink` returns `Boolean`:

* `true` — your listener handled the URL. The SDK treats it as consumed.
* `false` — your listener did not handle this URL. The SDK may hand it to another registered listener, or fall through.

On React Native, the listener does not return a value. Instead, the SDK hands you a `consumed` flag that tells you whether an earlier subscriber already handled this URL.

## Multiple listeners

All three SDKs support multiple listeners. Each listener sees every deeplink. The native iOS and Android SDKs consider the deeplink "consumed" if any listener returns `true`. On React Native, listeners receive a `consumed` flag indicating whether another listener already handled this URL.

In practice, most apps register one listener at app startup and route by URL prefix.

## Withdrawing a listener (native iOS / Android)

Requires apps built with SDK 0.9.0 or later. Registering **adds** a listener rather than replacing one and returns a token, so if you register from an object that is torn down and rebuilt while your app keeps running, call `removeDeepLinkListener(token)` when that object goes away. Otherwise the old listeners stay registered and every later deeplink is delivered to all of them.

Withdrawing takes the **token** the registration returned, so keep the token — a listener written inline is fine. An app that registers once at startup, as above, never needs this. On React Native, unsubscribe with the function you get back from `addDeepLinkListener` — see below.

A listener that throws is treated as "did not handle it", and the URL still reaches the listeners registered after it.

## When the app should wait for the outcome

A deeplink listener is fire-and-forget: the SDK hands you the URL and the campaign is done. Some actions instead need the user's flow to **wait** for them — a rewarded ad before an export, a survey before a cancel. Those are **gates**: you register them separately and the gated call site awaits the outcome. See [Gating actions](/developer-guide/gating-actions.md). Everything you don't register as a gate stays an ordinary deeplink, handled here.

## React Native: unsubscribing

`addDeepLinkListener` is async and returns an unsubscribe function. Always unsubscribe when the listener's owning component unmounts — otherwise closed-over state will leak:

```ts
useEffect(() => {
  let unsubscribe: (() => void) | undefined;
  Amply.addDeepLinkListener(handler).then(unsub => { unsubscribe = unsub; });
  return () => { unsubscribe?.(); };
}, []);
```

For a full teardown (e.g., sign-out), you can also call `Amply.removeAllListeners()` which unsubscribes every deep link listener the SDK tracks.

## Forwarding links to Amply

**SDK 0.15.0+.** Pass every link your app is opened with to `handleUrl`, before your own routing. It is what makes [Test on device](/user-guide/testing-and-rollout.md#test-on-device) work: the QR code opens your app with an Amply test link, and `handleUrl` shows the campaign.

`handleUrl` returns `true` only for an Amply test link. Then Amply has taken it over, and your app should not route it. For every other link it returns `false` and does nothing, so your routing stays as it is. Forward links both on a cold start and while the app is running. Test links use the **Deeplink scheme** set in the application's settings, which must be a scheme your app registers.

{% tabs %}
{% tab title="iOS (Swift)" %}

```swift
// SceneDelegate
func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
           options connectionOptions: UIScene.ConnectionOptions) {
    // … your setup …
    for context in connectionOptions.urlContexts { route(context.url) }   // cold start
}

func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
    for context in URLContexts { route(context.url) }                    // while running
}

private func route(_ url: URL) {
    if amply.handleUrl(url: url.absoluteString) { return }   // an Amply test link
    Router.shared.open(url)                                  // your own routing
}
```

An app without scenes forwards from `application(_:open:options:)` the same way.

Create the Amply instance before you forward links. A test link that arrives before the session starts is held for up to a minute and opened once it does.
{% endtab %}

{% tab title="Android (Kotlin)" %}

```kotlin
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        route(intent)                       // cold start
    }

    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        setIntent(intent)
        route(intent)                       // while running
    }

    private fun route(intent: Intent?) {
        val url = intent?.takeIf { it.action == Intent.ACTION_VIEW }?.dataString ?: return
        if (amply.handleUrl(url)) return    // an Amply test link
        router.open(url)                    // your own routing
    }
}
```

Create the Amply instance before you forward links. A test link that arrives before the session starts, which is usual in `onCreate`, is held for up to a minute and opened once it does.
{% endtab %}

{% tab title="React Native (TS)" %}

```ts
import { Linking } from 'react-native';
import Amply from '@amplytools/react-native-amply-sdk';

function route(url: string | null) {
  if (!url || Amply.handleUrl(url)) return; // an Amply test link
  openInApp(url);                           // your own routing
}

Linking.getInitialURL().then(route);                      // cold start
Linking.addEventListener('url', ({ url }) => route(url)); // while running
```

`handleUrl` is safe to call before `initialize`: a test link that arrives first is held and opened once initialization finishes.
{% endtab %}

{% tab title="Expo Router" %}
Forward from `app/+native-intent.tsx`, and only there, so each link is handed over once:

```tsx
import Amply from '@amplytools/react-native-amply-sdk';

export function redirectSystemPath({ path }: { path: string; initial: boolean }): string {
  if (Amply.handleUrl(path)) return '/'; // an Amply test link: go home, not to "not found"
  return path;
}
```

{% endtab %}
{% endtabs %}

## URL schemes

`yourapp://` throughout these docs is a placeholder for **a scheme your app owns and registers** (in `Info.plist` on iOS, your intent filter on Android) — Amply doesn't reserve or ship a deeplink scheme. A campaign simply carries whatever URL string you configure: a custom scheme like `yourapp://promo/123`, or an external `https://` link. Treat the `url` field as an opaque string; parse it however your app normally parses URLs.

External `https://` URLs coming from campaigns are typically opened by native code through the system's URL handling. The deeplink listener still receives them so the app can log or suppress them.

## Testing

You can simulate a campaign-style deeplink from the terminal:

```bash
# iOS Simulator
xcrun simctl openurl booted "yourapp://promo/test"

# Android device or emulator
adb shell am start -a android.intent.action.VIEW -d "yourapp://promo/test" <your.package.name>
```

Note: these open the OS-level URL. Campaign-driven deeplinks are triggered by matching a real campaign; see [Testing your integration](/developer-guide/testing-your-integration.md) for how to verify end-to-end.

## Related

* [Gating actions](/developer-guide/gating-actions.md) — for actions the user's flow must wait on (rewarded ad, survey)
* [Showing custom popups](/developer-guide/showing-custom-popups.md) — when the campaign wants the app to render a popup
* [Handling callbacks](/developer-guide/handling-callbacks.md) — other SDK events (session, config, init)
* [Concepts — Scenarios and campaigns](/concepts/scenarios-and-campaigns.md) — how the dashboard decides what deeplink to send
* [iOS SDK reference](/reference/sdk-ios.md) — exact signatures
* [Android SDK reference](/reference/sdk-android.md) — exact signatures
* [React Native SDK reference](/reference/sdk-react-native.md) — exact signatures
