Detour share image showing linked mobile and desktop app screens for deferred deep linking
Other
Integrating Deferred Deep Linking in Android Apps
Nov 6, 2026•10 min read

Deferred deep linking allows mobile apps to route users to specific in-app content even if the app wasn’t installed when the link was clicked – closing the attribution gap. Instead of landing on a generic home screen after install, the user is taken exactly where they were headed.

This tutorial focuses on the technical implementation using Detour – a deferred deep linking tool build with developers in mind. We will focus on the technical implementation: getting the SDK running, handling the install ‘snapshot’, and passing custom data through app installation into your navigation.

A deep link routes a user directly to a specific screen in your app – but only if the app is already installed. A deferred deep link does the same, but also survives the app installation process: if the app isn't installed yet, the link preserves the user's intended destination and context, routing them to the right screen on the very first open.

In practice, every deferred deep link is a deep link, just with an extra fallback path: install → first launch → match → route. That's the flow this guide walks through step by step, and it's also why iOS needs probabilistic matching (covered later) – unlike Android, there's no install referrer to rely on, so the SDK has to reconstruct the original link from device signals after the fact.

As I've mentioned, for deferred deep linking to work, we need a middleman to persist information between the click and the app installation. This requires simple configuration in our dashboard.

First, sign up and create an organization and your application. If you have multiple environments, we suggest creating separate apps using a naming convention like <your-app>-staging and <your-app>-production.

App_Creation_c9ce78fccd.gif
App creation in the Detour panel 

Android requirements

  • Package name: this is your applicationId, found in your app-level build.gradle file.
  • Production certificate (SHA-256): since you're likely using Google Play App Signing, you must grab this from the Google Play Console.
  • Debug certificate (SHA-256): used for testing during local development. You can find this by running ./gradlew signingReport.

The fingerprint has to match the key that actually signed the build on the device – App Links only open your app when it does.

iOS requirements

If your product ships on iOS as well, fill in the iOS card too – Bundle ID, Team ID, and App Store ID.

large_App_confifuration_559bcfa81a.webp
App configuration in the Detour panel

There is also an option to improve matching efficiency by using clipboard content.

Installing the deferred deep linking SDK for Android

This guide covers Android; if you're on a different stack, we have companion guides for React Native, iOS, and Flutter.

The Android SDK is a plain Gradle dependency. It requires min SDK 21, Kotlin 2.0+, and a JVM target of 11.

// app/build.gradle.kts
dependencies {
    implementation("com.swmansion.detour:detour-sdk:1.1.0")
}

Keep google() and mavenCentral() in your repositories and that's the whole installation – there are no companion packages to pull in for fingerprinting, since the SDK reads device signals from the platform directly.

Credentials shouldn't be hardcoded. The pattern used across our example apps is to route them through BuildConfig:

// app/build.gradle.kts
buildConfigField("String", "DETOUR_API_KEY", "\"${secrets["DETOUR_API_KEY"] ?: ""}\"")
buildConfigField("String", "DETOUR_APP_ID", "\"${secrets["DETOUR_APP_ID"] ?: ""}\"")

Next, for the App Links part to work, paste the integration snippet generated in the dashboard into your AndroidManifest.xml:

<activity
  android:name=".MainActivity"
  android:exported="true">
  <intent-filter android:autoVerify="true">
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="https" />
    <data android:host="<your-host>" />
    <data android:pathPrefix="/<your-app-hash>" />
  </intent-filter>
</activity>

If you also want custom scheme links, add a second intent filter with <data android:scheme="myapp" />. Without these entries, Android will never dispatch the link to your app in the first place.

Finally, define your config and initialize the SDK once, before anything tries to process a link:

val config = DetourConfig(
    apiKey = BuildConfig.DETOUR_API_KEY,
    appId = BuildConfig.DETOUR_APP_ID,
    linkProcessingMode = LinkProcessingMode.ALL,
)

Detour.initialize(applicationContext, config)

linkProcessingMode decides which link sources the SDK claims. ALL is the default and handles App Links, custom schemes, and deferred links. WEB_ONLY skips custom schemes, and DEFERRED_ONLY leaves every runtime intent to your own navigation stack and resolves deferred links only – which is the mode you want if Jetpack Navigation already owns your deep links.

One more thing worth knowing: the SDK stores a first-launch flag and a device ID in SharedPreferences. If you want it kept somewhere else, pass your own storage in the config:

val config = DetourConfig(
    apiKey = BuildConfig.DETOUR_API_KEY,
    appId = BuildConfig.DETOUR_APP_ID,
    linkProcessingMode = LinkProcessingMode.ALL,
    storage = MyStorage(context),
)

The custom storage docs have a ready example.

The library handles the fingerprinting logic, but the biggest headache for developers has always been navigation integration. Auth gates, deep nesting, weird glitches, or the same deferred link triggering multiple times – are just a few of the common pain points that come to mind.

For the sake of this example, let's simplify the structure: we'll use an app with a sign-in screen, as well as a home screen and an onboarding screen – both of which are protected by an auth gate.

The lifecycle side is handled by DetourDelegate. It wraps the coroutine calls to Detour.processLink(), ties them to your LifecycleOwner, and hands you a LinkResult in a callback. You wire it into two places – onCreate for the launching intent and cold-start deferred resolution, and onNewIntent for links that arrive while the app is already running:

// MainActivity.kt
class MainActivity : AppCompatActivity() {

    private val linkViewModel: DetourLinkViewModel by viewModels()

    private val detourDelegate by lazy {
        DetourDelegate(
            lifecycleOwner = this,
            config = DetourConfig(
                apiKey = BuildConfig.DETOUR_API_KEY,
                appId = BuildConfig.DETOUR_APP_ID,
                linkProcessingMode = LinkProcessingMode.ALL,
            ),
            onLinkResult = { result -> linkViewModel.onLinkResult(result) },
        )
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        val splashScreen = installSplashScreen()
        super.onCreate(savedInstanceState)

        // Hold the system splash until the SDK tells us whether there is a link.
        splashScreen.setKeepOnScreenCondition { !linkViewModel.state.value.isLinkProcessed }

        Detour.initialize(this, detourDelegate.config)
        detourDelegate.onCreate(intent)

        setContent { AppRoot(linkViewModel = linkViewModel) }
    }

    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        setIntent(intent)
        detourDelegate.onNewIntent(intent)
    }
}

The callback gives you a sealed LinkResult, and collapsing it into a single piece of UI state is what makes the gate readable later. Note that only Success carries a link – the other three branches all mean "carry on with a normal launch", and the important thing is that every one of them still flips isLinkProcessed, otherwise your splash never goes away:

// DetourLinkViewModel.kt
data class DetourLinkState(
    val isLinkProcessed: Boolean = false,
    val link: LinkResult.Success? = null,
)

class DetourLinkViewModel : ViewModel() {
    private val _state = MutableStateFlow(DetourLinkState())
    val state: StateFlow<DetourLinkState> = _state.asStateFlow()

    fun onLinkResult(result: LinkResult) {
        _state.update { current ->
            when (result) {
                is LinkResult.Success -> current.copy(isLinkProcessed = true, link = result)
                // Deferred flow already consumed for this install.
                is LinkResult.NotFirstLaunch -> current.copy(isLinkProcessed = true)
                is LinkResult.NoLink -> current.copy(isLinkProcessed = true)
                is LinkResult.Error -> {
                    Log.e(TAG, "Link processing failed", result.exception)
                    current.copy(isLinkProcessed = true)
                }
            }
        }
    }

    fun clearLink() = _state.update { it.copy(link = null) }

    private companion object {
        const val TAG = "DetourLinkViewModel"
    }
}

Now the gate itself. It has to wait for both the SDK and your auth layer, survive an onboarding detour without dropping the link, and not re-navigate on every recomposition:

// AppRoot.kt
@Composable
fun AppRoot(
    linkViewModel: DetourLinkViewModel,
    authViewModel: AuthViewModel = viewModel(),
) {
    val navController = rememberNavController()
    val linkState by linkViewModel.state.collectAsStateWithLifecycle()
    val auth by authViewModel.state.collectAsStateWithLifecycle()

    // Guards the "normal startup" branch so it only fires once per sign-in
    // session. Set to true after any successful navigation; reset on sign-out.
    var initialNavigationFired by rememberSaveable { mutableStateOf(false) }

    LaunchedEffect(linkState, auth) {
        if (!linkState.isLinkProcessed || !auth.isLoaded) return@LaunchedEffect

        if (!auth.isSignedIn) {
            initialNavigationFired = false
            navController.navigateAsRoot(Route.SignIn)
            return@LaunchedEffect
        }

        val link = linkState.link
        if (link != null) {
            initialNavigationFired = true

            // Onboarding must run once before the deep link destination is shown.
            // Keep the link alive so this branch re-fires after onboarding completes.
            if (!auth.isOnboardingCompleted) {
                navController.navigateAsRoot(Route.Onboarding)
                return@LaunchedEffect
            }

            // Onboarding done — navigate to the link destination.
            // Clear before navigating so the re-fire caused by link→null is skipped.
            val destination = link.toRoute()
            linkViewModel.clearLink()
            navController.navigateAsRoot(destination)
            return@LaunchedEffect
        }

        if (initialNavigationFired) return@LaunchedEffect
        initialNavigationFired = true

        navController.navigateAsRoot(
            if (auth.isOnboardingCompleted) Route.Home else Route.Onboarding
        )
    }

    NavHost(navController = navController, startDestination = Route.Splash) {
        composable<Route.Splash> { SplashScreen() }
        composable<Route.SignIn> { SignInScreen() }
        composable<Route.Onboarding> { OnboardingScreen() }
        composable<Route.Home> { HomeScreen() }
        composable<Route.Product> { entry -> ProductScreen(entry.toRoute<Route.Product>()) }
    }
}

private fun NavController.navigateAsRoot(route: Route) = navigate(route) {
    popUpTo(graph.id) { inclusive = true }
    launchSingleTop = true
}

The only piece left is turning the link into one of your typed routes. LinkResult.Success gives you url, route (path plus query), pathname (path only), params (the query parsed into a map), and type, which tells you whether the link arrived as DEFERRED, VERIFIED, or SCHEME:

private fun LinkResult.Success.toRoute(): Route = when {
    pathname.startsWith("/products/") ->
        Route.Product(
            id = pathname.removePrefix("/products/").trim('/'),
            campaign = params["utm_source"],
        )

    pathname.startsWith("/promo") -> Route.Promo

    else -> Route.Home
}

Because type is on the same object, one branch of your routing code covers deferred installs, App Links, and scheme links alike – which is the main practical difference from wiring each of them up separately.

By default, every application in our dashboard has a link assigned to it, to which you can append any URL parameters you want:

https://your-org.godetour.link/<app-id>

For example, if you wanted to redirect to a product screen, you would just append this path:

https://your-org.godetour.link/<app-id>/products/42

And if you wanted to let's say track a marketing campaign, you would add the UTM properties like that:

https://your-org.godetour.link/<app-id>/products/42?utm_source=SP&utm_origin=web&utm_screen=header

Everything after the app hash lands in result.route, and the query string is parsed into result.params for you.

While this manual approach is highly flexible, adding too many parameters can result in long, unwieldy URLs that aren't ideal for public-facing content.

The solution is to create a short link within our management panel. This allows you to predefine your parameter list and condense the entire string into a clean, professional-looking hash that is much easier to share.

Verifying this flow is straightforward, though there are a few platform-specific nuances to keep in mind:

  • Store presence: your application must be available on the Google Play Store to successfully complete the required redirect chain.
  • Android referrer API: while simulating this API often requires specific workarounds, you can easily verify the flow using probabilistic matching as a simpler alternative.
  • First launch only: deferred matching runs on the first launch after install, so reinstall the app between test runs.

With all these considerations in place, you can just test your link by opening it in your browser, or by scanning the QR code from our panel.

For ADB commands plus a troubleshooting checklist, see the testing docs.

While this process is optimized for mobile apps, you may wonder what happens when a user clicks a link on a desktop browser. By default, users are redirected to the Google Play Store; however, if you have a relevant website, you can configure it as a fallback URL. Furthermore, if your links include specific attribution parameters – such as a chat invitation ID – you can choose to pass these through to your web platform and handle everything in there as well.

Detour web fallback settings with a redirect URL and forwarded URL parameters

In some cases, the matching score might not be high enough for the process to succeed. While the default value is 850, you can always adjust this in our panel if there is a high probability that certain attribution parameters might not be captured properly.

The same flexibility applies to the matching time window – the duration between the initial user click and the matching process once the app download is complete. By default, this is set to 15 minutes, which is typically sufficient for a standard installation, but the setting is customizable to fit your specific case.

Detour dashboard page showing short-link statistics and matching configuration

Have any questions? The Detour team is here to help

We're building Detour specifically for developers, which means your feedback is our roadmap. If you have questions or feature ideas, please reach out to us:

And if there are any technical details you are interested in, check out our documentation and examples.

Share this article