Skip to main content
deep-linking

Deferred Deep Linking on Android: How to Implement It in Kotlin

Implement deferred deep linking on Android with Kotlin. Use the Play Install Referrer, handle missing referrers, route first opens, and test on real devices.

WarpLink Team18 min read

Deferred deep linking on Android hands a tapped link to your app the first time it opens after a Play Store install. The redirect adds a referrer naming the link to the Play Store URL, the store keeps it with the install, and the SDK reads it back on first launch for an exact match at confidence 1.0. When the referrer is missing, the server falls back to a scored probabilistic match on IP, language, and timezone.

In Kotlin the whole deferred path is one configure() call in Application.onCreate():

The SDK brings Google's Install Referrer library, runs the check once per install, and needs no advertising ID. The rest of this guide covers how the referrer travels, what happens when it does not, how to route on the result, and how to test both paths.

Android App Links solve one problem well. The user has your app, taps an https:// link on a domain you verified with assetlinks.json, and the system routes the intent straight into your Activity. When the app is not installed, the link opens in Chrome, Chrome follows the redirect to the Play Store, the user installs and taps Open, and the app starts on its launcher screen. The intent that would have carried the URL was never created, because there was no app to send it to.

Deferred deep linking fills that gap by matching instead of delivering: the tap and the first launch happen on opposite sides of an install and are stitched back together afterwards. For the general mechanism and how it differs from a standard link, see deep link versus deferred deep link. What makes Android the easy platform is that the Play Store can carry a string through the install, so nobody has to guess.

How the Play Install Referrer Works

The click side: a query parameter on the store URL

The Play Store honors a referrer query parameter on a listing URL. Whatever is in it is stored against that install and handed back to the app afterwards. WarpLink's redirect appends its own pair whenever the resolved destination is a play.google.com URL, including the store listing your app registration supplies as the fallback:

utm_source=warplink marks the referrer as one the SDK should claim. Any other source is the ordinary organic case, reported as such rather than as a failure. utm_content={link_id} is the payload, the link's UUID, and the SDK validates its shape before trusting it, so a truncated or rewritten value is rejected rather than sent up as a lookup that cannot resolve. A referrer value you set yourself is merged rather than refused: WarpLink's pair goes first and yours is appended, because the parser keeps the first occurrence of each key.

The install side: Google's Install Referrer library

On first launch the SDK connects to the Play Store app through Google's Play Install Referrer library, com.android.installreferrer:installreferrer, version 2.2. The WarpLink Android SDK depends on it already, so there is nothing to add to your build beyond the SDK itself.

Three properties of that read shape the design:

  • It is asynchronous. The library binds to a service in the Play Store app. The SDK gives it a 2 second budget, then moves to the probabilistic path so first launch never blocks.
  • It is only worth calling once. Google's guidance is to read it on the first run after install. The SDK does exactly that, gated by the completion marker described below.
  • It carries a timestamp. Alongside the referrer string, the library returns installBeginTimestampSeconds, the moment the Play install began. That separates a genuine first open from an app that has sat on the device for weeks.

When the referrer names a WarpLink link, the SDK sends the link ID with the attribution request and the server records a deterministic match: matchType is DETERMINISTIC, matchConfidence is 1.0, and matchGuaranteed is true.

The 90 day window, and the 7 day routing window

Google documents the install referrer as available for 90 days, and it does not change unless the app is reinstalled. That creates a case worth handling deliberately. Suppose a user installed your app from a campaign link six weeks ago, and you are only now shipping the release that includes an attribution SDK. On that first launch the referrer is read for the first time, still inside Google's 90 days. Attributing the install to the original link is correct. Dropping a six-week user into the product page they tapped back then is not. Past 90 days there is nothing left to read, and the check falls back to the probabilistic match like any other missing referrer.

The SDK splits those decisions. It attributes the install regardless of age, and routes the user only when the Play install began within the last 7 days, the click-to-install window deferred deep linking is built for. Past that the check settles with nothing to route and your app opens normally. An unknown timestamp routes, on the principle that silence is not evidence of age.

Set Up the SDK in Kotlin

The SDK supports Android API 26 and above. Add the dependency to your app module's build.gradle.kts:

Step 1: Configure once, in Application.onCreate

configure() registers cold-start handling and fires the deferred check for you. One onLink callback receives everything: a tapped App Link, a warm-start intent, and a deferred install match, told apart with isDeferred.

Register the class with android:name=".MyApp" in AndroidManifest.xml, declare android.permission.INTERNET, and forward warm-start intents with WarpLink.onNewIntent(intent) from the Activity that receives your App Links.

The credential is an SDK key, created in the dashboard under Keys, then SDK keys. Configure the SDK with an SDK key, since only an SDK key records installs. A malformed key does not throw: configure() logs a warning, delivers WarpLinkError.InvalidApiKeyFormat to your onLink, and leaves the SDK unconfigured, which keeps it safe to call from Application.onCreate().

Step 2: Take the check manually when you need the no-match answer

onLink is invoked for a deferred match. When there is no match, nothing is dispatched, because a no-match is not a link. If your first screen needs that answer, for example to hold a splash screen until you know which way to go, call checkDeferredDeepLink yourself and turn off the automatic check so it does not run twice:

Then, in the Activity that owns your first-launch experience:

Every callback is dispatched on the main thread, so driving navigation from it is safe. The three second cap is your own policy: the referrer read alone is budgeted at 2 seconds and a network round trip follows it. If the timeout wins, the check is not consumed and it retries on the next launch. Calling checkDeferredDeepLink while a check is already in flight does not start a second one. The later caller is parked and answered with the running check's result.

Handle a Missing Referrer

The referrer path is the primary one, and the probabilistic path covers the rest. These are the cases it covers:

  • The app was sideloaded. An APK installed with adb install, or handed over as a file, never went through the Play Store.
  • The device has no Play Store, so nothing can bind to the referrer service.
  • The link's destination was not a Play Store URL, for example a web page, so no referrer was attached at the tap.
  • The Play Store service refused. The library reports a response code rather than an exception: service disconnected, service unavailable, feature not supported, developer error, or a denied bind. The SDK names the response code in Logcat under the WarpLink tag.
  • The referrer data expired, or the referrer is not ours: another utm_source, or a utm_content that is not a valid link identifier.

Every one of those produces the same result: no link ID, so the SDK falls back to the probabilistic match with no code change on your side.

What the match key is made of

The SDK collects three signals and sends them raw. Nothing is hashed on the device:

SignalSourceNotes
Preferred languageLocale.getDefault().toLanguageTag()Sent raw, for example en-US. The server normalizes it to the primary subtag.
Timezone offsetTimeZone.getDefault().getOffset(now)In minutes, daylight-saving aware.
IANA zone nameZoneId.systemDefault().idFor example America/Toronto. Empty when the process default zone has no tzdb entry.

The IP address is the third component, derived on the server from the address the attribution request arrives on. An app cannot reliably see its own public IP, so a device-computed IP hash could never match the one computed at the tap. Letting the server do it on both sides means the two hashes are built the same way.

The SDK collects no User-Agent, no screen size, no advertising ID, no Android ID, no location, and no cross-app identifier. A device whose zone name cannot be resolved still sends its language and offset.

How the match key is scored

There are three variants. enriched_tz hashes the IANA zone name and is what current SDKs send. enriched hashes the minute offset instead and remains the path for older SDKs and for devices that cannot resolve a zone. basic drops the timezone and keys on IP and language alone.

Time since clickenriched_tzenrichedbasic
< 1 hour0.850.800.70
< 3 hours0.650.600.50
< 6 hours0.500.450.35
< 24 hours0.300.250.20

A referrer match sits above this table at 1.0. The zone name earns the higher ceiling because it carries far more entropy than the offset, roughly 340 zones against 38 offsets, and it does not shift at a daylight-saving boundary.

Two multipliers then apply to the score: x0.6 for a bucket that held more than one distinct link, and an IP-sharing factor of x0.6 for carrier-grade NAT or a private address, x0.9 for a household IPv4 address, and x1.0 for IPv6. Clicks that share a match key are kept as a list, newest first, capped at 10, so a second person behind the same address does not overwrite the first.

The match window is set per link in the dashboard, not in the SDK: 6 hours by default, anywhere from 1 to 24 hours, and past it there is no probabilistic match at all. The window is short by design, which keeps the match key bucket tight and the matches relevant. The window governs the match key only. The referrer branch reads an install record, so a short window costs it nothing.

Route a First-Open Match

WarpLinkDeepLink carries the destination, the link's Android deep link URL when it has one, and three attribution fields. They are not interchangeable:

  • matchGuaranteed: Boolean is true only for a deterministic match, which on Android means the Play Install Referrer named this link.
  • matchType: MatchType? is DETERMINISTIC or PROBABILISTIC, useful for logging and tuning.
  • matchConfidence: Double? is the score, useful for reporting and for softening a probabilistic route.

Route to specific public content above 0.5 and degrade gracefully below it, which can mean a welcome screen that names the content so the user opts in. None of the three fields authorizes anything sensitive on its own: automatic sign-in, restoring a session, showing personal data, and resuming a purchase all need the user authenticated and authorization checked separately.

deepLinkUrl is the link's own Android deep link URL, returned verbatim, so campaign values written onto the link survive the store round trip. That is the bridge from routing the user to attributing the install.

First launch, completion markers, and reinstalls

The check runs once per install. The SDK keeps two markers with opposite lifetimes:

The completion marker is a file in the app's no-backup storage, noBackupFilesDir. Android Auto Backup never captures that directory, so the marker dies with the install that wrote it. It is written only after the server gives a definitive answer, a match or a confirmed no-match, so an offline first launch retries next launch instead of caching a failure. If the directory cannot be written, the marker falls back to a SharedPreferences entry bound to this install's first-install time, so a restored copy from an older install cannot gate the new one.

The device-seen marker is a plain SharedPreferences entry, and Auto Backup does restore it. It gates nothing. Its only job is to set is_reinstall: true on the attribution request, so an install that follows an earlier attributed one on the same device can be told apart.

A reinstall is a new install: the completion marker is gone, so the check runs again and the install is attributed again. Both count, and the dashboard shows them together as one installs number. No manifest or backup_rules.xml changes are needed. If you set android:allowBackup="false", the device-seen marker cannot survive an uninstall, so a reinstall on that device is reported as a first install. Attribution is unaffected, and the SDK logs the caveat when debug logging is on.

Test Store and Sideload Paths

The probabilistic path is easy to test. The referrer path runs on any install the Play Store performs.

The probabilistic path, which is also the sideload path:

Keep the gap between the tap and the launch inside an hour, both to stay inside the match window and to land in the top confidence band. With debugLogging = true the SDK logs the referrer outcome, the fallback decision, and the matched link ID under the WarpLink tag.

The referrer path needs an install the Play Store performed. An internal testing track is the usual way to get one without a public release: upload the build, opt a tester in, and install from the store listing rather than from Android Studio. Confirm on your own build that the referrer survives your opt-in flow rather than assuming it, and read the Logcat line to see which branch fired. A good result reads matchType of DETERMINISTIC, matchConfidence of 1.0, and matchGuaranteed true.

A physical device is worth the trouble throughout, since emulator images without Play Services cannot exercise the referrer branch. To check the installed path, adb shell am start -W -a android.intent.action.VIEW -d "https://yourapp.aplnk.to/your-slug" com.yourcompany.yourapp sends the link as an intent, and adb shell pm get-app-links com.yourcompany.yourapp (Android 12 and above) reports whether the system verified your domain.

SymptomLikely causeWhat to check
The match is probabilistic, never exactThe install did not come from the Play StoreAndroid Studio, adb install, and file installs carry no referrer. Use an internal testing track.
onLink never fires on a rerunThe check already completed for this installA rerun over an existing install returns the cached answer. Uninstall first, every time.
Onboarding never hears "no match"onLink only dispatches matchesTake the manual path in step 2 above.
No match after a slow installThe tap aged out of the match windowThe window is 6 hours by default, set per link.
Attributed, but the app does not routeThe Play install began more than 7 days agoDebug logging says so explicitly.
A network error on first launchThe device was offlineThe attempt is not consumed and retries next launch.
Installs missing from the dashboardConfigure the SDK with an SDK key, not an API keyThe key's type under Keys in the dashboard.
A probabilistic test failsThe tap and the install were on different networksTap and install on the same network.
Logcat shows a referrer response codeThe Play Store service refused the bindFeature not supported and permission denied are device conditions, not code bugs.

If the link fires and the app opens Chrome rather than your Activity, that is App Links verification, not deferred matching, and it has its own guide below. Check the domain with our assetlinks.json validator first.

Frequently Asked Questions

What is a deferred deep link and how does it work? A deferred deep link carries a link's destination through an app install, so a user who taps it without the app lands on the intended screen the first time the app opens. On Android the redirect adds a referrer naming the link to the Play Store URL, the Play Store keeps it with the install, and the SDK reads it back on first launch for an exact match. When there is no referrer, the server matches the launch to the tap on a match key of IP, language, and timezone instead.

How do I check deep linking on Android? For an installed app, send the link with adb shell am start -W -a android.intent.action.VIEW -d followed by the URL and your package name, and run adb shell pm get-app-links with your package on Android 12 and above to see whether the domain is verified. For the deferred path, uninstall the app, tap the link on the device, install, launch, and watch adb logcat -s WarpLink with debug logging on.

Does deferred deep linking on Android need Google Play Services? The exact path does, because the Play Install Referrer is read from the Play Store app over a service binding. On a device with no Play Store, that binding fails and the SDK falls back to the probabilistic match automatically. You write no extra code for that fallback.

Does deferred deep linking work with a sideloaded APK? Only through the probabilistic fallback. An APK installed with adb install or from a file never passed through the Play Store, so there is no referrer to read and the exact branch cannot fire. The SDK sends the device signals instead, which still produces a match when the tap was recent and on the same network.

How long does the Play Install Referrer last? Google documents the install referrer as available for 90 days, and it does not change unless the app is reinstalled. That is far longer than the probabilistic match window, which defaults to 6 hours per link. The WarpLink SDK still attributes an install whose referrer is weeks old, but it stops routing the user into that content once the Play install began more than 7 days ago.

Why is the match deterministic on Android but not on iOS? The Play Store carries the click referrer through the install and the App Store carries nothing. On Android the server is told exactly which link produced the install, so confidence is 1.0 and matchGuaranteed is true. On iOS every first install is matched probabilistically and scored.

Do I have to call checkDeferredDeepLink myself? No. configure() fires the deferred check for you and delivers a match to your onLink callback with isDeferred set to true. Call checkDeferredDeepLink yourself only when you want the no-match answer too, for example to hold a splash screen, and set automaticDeferredDeepLinks = false so the check does not run twice.

The mechanism above is the same whoever runs it. What you cannot host inside your app is the other half: the redirect that appends the referrer to the store URL and records the click's signals before the Play Store takes over, and the attribution endpoint that computes the match key from the request IP and walks the cascade when the referrer is not there. WarpLink is those two halves, and in Kotlin they collapse to one configure() call.

Deferred deep linking is also install attribution. The same match that routes the user tells you which link, campaign, and channel drove the install, because linkId, deepLinkUrl, matchType, matchConfidence, and matchGuaranteed come back in one payload. That is the bridge from the linking pillar to the attribution pillar, and from there into the real-time analytics that show which taps become users.

Create a free WarpLink account and get deep linking, install attribution, and the analytics behind them in one SDK, with 10,000 clicks a month on the free tier and no time limit.

Get your first deep link working today

Deep linking, install attribution, and real-time analytics in one SDK. Free for 10K clicks a month, no sales call.

Stuck on setup? Email support@warplink.app and we will help you get one real link working.

WarpLink Team

Building affordable, reliable link infrastructure for mobile teams. Deep linking, install attribution, and real-time analytics in one SDK.