deep-linking

Deferred Deep Linking in Flutter: One Callback for iOS and Android

Deferred deep linking Flutter guide: route the first open after install with one WarpLink 1.1.0 callback, plus first-open test steps and match limits.

WarpLink Team··26 min read

TL;DR: Deferred deep linking in Flutter lets a user tap a link, install your app from the App Store or Play Store, and still land on the right screen on first launch. Flutter's own deep link handling cannot do this, because it reports a URL the OS handed your app and a fresh install has no URL to hand over. The work happens in two halves instead: the click is recorded at the edge with the signals a browser exposes, and on first launch the native SDK collects the device's own signals so the server can match the install back to that click. The two platforms get there differently. Android reads the Play Install Referrer for a deterministic match, while a genuine first install on iOS falls back to a probabilistic fingerprint. Both surface through one Dart callback, where deepLink.isDeferred is true and matchGuaranteed tells you whether the match was deterministic or a guess. With warplink_flutter 1.1.0 that is one WarpLink.configure(apiKey:, onLink:) call and no per-platform branch. The rest of this guide is the mechanism, the Dart, and the testing.

The Minimal End-to-End Example

This is the whole integration for the first open after an install, written against warplink_flutter 1.1.0. Each part is explained further down.

configure() starts the deferred check on its own. The native SDK records the install, the server matches it to the click, and a match arrives in onLink with isDeferred set to true. No other call is required for the first open. The sections below add the platform setup, the navigation routing, and the testing steps that keep the result honest.

Why Flutter Has No Deferred Deep Linking API

Flutter gives you one door for incoming links, and it is the deep link handler the framework wires to the platform. It forwards a URL to your router as a route, and between that and the plugins built on it you cover every link that arrives while your app exists on the device.

That last clause is the whole problem. Every one of those paths is a reporter: it tells you about a URL the operating system already routed into your process, and the OS only routes a URL into a process that is installed and registered for that host. When the user taps your link without the app, there is no process, no registration, and no URL event. iOS sends them to the App Store, Android sends them to the Play Store, and the link's destination stops there. The user installs, opens the app, and the launch link is null, not because anything failed but because nothing was delivered.

Deferred deep linking is the workaround for a delivery channel that does not exist. Instead of transporting the link through the install, it records the click on one side, records the install on the other, and matches the two afterwards. This is also why no router configuration fixes it: a go_router route table is a URL-to-screen mapping, and there is no URL. The deferred result arrives as a plain object from a native SDK, so it is your code, not the router, that routes it.

Two Platform Mechanisms Under One Dart API

Cross-platform deferred linking is interesting in Flutter because iOS and Android solve the same problem with different machinery, and you want one code path on top of both. The plugin adds no signal of its own. The native iOS and Android SDKs collect everything, so attribution in a Flutter app is identical to a native app.

Android: the Play Install Referrer

Android has a real answer. A redirect to the Play Store can attach a referrer string that the Play Store carries through the install and hands to the app afterwards. WarpLink sets that referrer to utm_source=warplink&utm_content={link_id}, so on first launch the native Android SDK reads it, sees the link id in plain text, and reports a deterministic match with confidence 1.0.

No inference and no window: the referrer names the click. This is why a test that works on Android tells you very little about how the same flow behaves on iPhone.

The referrer is not always there. It is missing when the app was sideloaded through adb install or a direct APK, when the device has no Google Play Services (Huawei devices running HMS, for example), and when the referrer data has expired. In each case the Android SDK falls back to the same fingerprint the iOS SDK uses, with no code change on your side.

iOS: IDFV first, then a fingerprint

iOS has no referrer at all. The App Store passes nothing through the install, so the strongest available signal is the Identifier for Vendor. The IDFV is stable across all apps from the same vendor on a device, it is always readable, and it is exempt from App Tracking Transparency. It produces a deterministic match with confidence 1.0 when the same install asks again, which is the re-engagement case. The server skips the IDFV replay when the request is flagged is_reinstall. A reinstall gets a new check and falls through to the fingerprint match, so it is recorded as a new install.

Note the wording, because it is narrower than most write-ups suggest. A genuine first install on iOS, the case you care about for a share link or a campaign, is matched probabilistically.

That tier compares signals captured when the link was tapped in the browser against signals collected when the app first launched: the IP address, the normalized primary language, and the timezone. The SDK sends the language and the timezone, the IANA zone name and the minute offset. It never sends the IP, because an app cannot see its own public address, so the server derives it from the request on both the click and the install side. Computing both halves in the same place is the only way the two hashes can agree.

The single Dart surface

Neither mechanism leaks into your code. Both platforms return the same WarpLinkDeepLink through the same callback, and the only thing that differs by platform is the confidence in it:

PropertyTypeMeaning
linkIdStringIdentifier of the link in the dashboard
destinationStringThe web destination URL
deepLinkUrlString?The in-app URL for this OS, or null when the link has none
customParamsMap<String, Object?>Custom key and value pairs set on the link, empty when none
isDeferredbooltrue for an install match, false for a tap
matchTypeMatchType?MatchType.deterministic, MatchType.probabilistic, or null
matchConfidencedouble?Confidence from 0 to 1, or null when not matched
matchGuaranteedbooltrue only for a deterministic match

isDeferred separates an install match from an ordinary tap. matchGuaranteed is what you branch on when the routing decision matters. Everything else is payload. A link resolved by a tap has isDeferred: false, a confidence of 1.0, and matchGuaranteed: true, so one routing function can serve both.

iOS and Android side by side

Install pathHow the match is madematchTypematchConfidencematchGuaranteed
Android, installed from the Play StoreThe Play Install Referrer names the clickdeterministic1.0true
Android, sideloaded or no Google Play ServicesFingerprint: IP address, language, timezoneprobabilisticUp to 0.85 inside the first hourfalse
iOS, first install from the App StoreFingerprint: IP address, language, timezoneprobabilisticUp to 0.85 inside the first hourfalse
iOS, the same install asks againThe IDFVdeterministic1.0true
Either platform, click older than the match windowNo matchnull resultn/an/a

The Confidence Model and What to Trust

A deterministic match, whether it came from the Play Install Referrer or the IDFV, returns matchConfidence of 1.0, matchType of deterministic, and matchGuaranteed set to true. A probabilistic match returns a score that decays with the gap between click and install, and matchGuaranteed stays false.

The probabilistic ceilings depend on the fingerprint variant. Current SDKs send the IANA zone name, for example America/Toronto, which is the enriched_tz variant. Older SDKs send the minute offset instead, the enriched variant. When neither is usable, basic 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

The zone name earns the higher ceiling because it carries far more entropy than the offset, roughly 340 zones against 38 offsets, and does not shift at a daylight saving boundary.

Two multipliers then reduce whichever ceiling applied, and they only ever reduce it, because a confident wrong answer is worse than an honest uncertain one. More than one claimable click sharing the fingerprint applies x0.6. The click's IP address applies x0.6 for carrier grade NAT or a private address, x0.9 for a household IPv4 address, and x1.0 for IPv6.

The match window governs this tier only. It is set per link on the server, in the dashboard, not in the SDK. The default is 6 hours and the ceiling is 24, so the bottom row of that table only applies to links configured past the default. The deterministic branches are unaffected by it.

The window is short deliberately. The fingerprint key describes a network, not a device, so every phone behind one shared address that shares a language and timezone lands in the same bucket, and each extra hour lets another stranger join it while adding almost no real matches. Inside the first hour, probabilistic matching is strong. By the end of the day it is a hint.

The practical rule that falls out of this: matchGuaranteed is not a credential, so use it, like a confidence threshold, to pick a destination. A probabilistic match can name the wrong person, so it should only ever route to a low-risk destination with a safe fallback, never sign someone in, restore a session, show personal data, or resume a purchase. Confidence is the second routing decision, and it only applies once matchGuaranteed is false: route to specific public content above 0.5, soften below it. Authenticate the user and check authorization separately before anything sensitive, whether or not the match was guaranteed.

Wiring It Up: One Call, Both Platforms

The package is warplink_flutter 1.1.0, published on pub.dev under the verified publisher warplink.app, MIT licensed with no third party runtime dependencies. It needs Flutter 3.38 or newer, Dart 3.10 or newer, iOS 15 or newer, and Android API 26 or newer. It runs on iOS and Android only. On web and desktop every call throws UnsupportedError.

The iOS side comes through Swift Package Manager. Flutter 3.44 and later enable it by default. On Flutter 3.38 to 3.43, run this once on the machine that builds the app:

There is no Podfile change. On Android, set minSdk to 26 in android/app/build.gradle.kts, because Flutter's default can be lower and the manifest merge then fails:

Then call configure() once at startup, from the initState of your root widget, and detach the future with unawaited. Put the routing in its own function, so the callback stays short:

That single call wires three sources into one callback: cold start (launched by a link), warm start (foregrounded by a link), and the deferred check. deepLink.isDeferred tells the third apart from the first two.

Three behaviors of configure() are worth knowing before you debug anything. It does not throw on a malformed key: it checks the format before any native call, reports a bad one through onLink as an ErrorEvent with code E_INVALID_API_KEY_FORMAT, prints a warning, and leaves the SDK unconfigured. An exception thrown by your own onLink while configure delivers a rejected key or a configuration error propagates out of the future it returns, and an exception thrown for a link that arrives later surfaces as an uncaught error and does not stop the SDK. And the future completes when native configuration completes. It does not wait for the first cold-start or deferred delivery.

Either automatic piece can be disabled and driven yourself:

Note the asymmetry. Omitting onLink does not switch the deferred check off, because that request is what attributes the install. Only automaticDeferredDeepLinks: false stops it. Use an SDK key, created under Keys > SDK keys in the dashboard. An API key passes the format check and resolves deep links, but it cannot record installs, so attribution stays empty.

Native host setup, and what deferred matching does not need

Setup for tapped links is configuration only, and none of it is required for deferred matching. Separate them so you debug the right layer.

On iOS, add the Associated Domains entry for your app's link host, applinks:yourapp.aplnk.to, to the Runner target. On Android, add an autoVerify intent filter for the same host inside your main <activity>, and keep the android:launchMode="singleTop" that flutter create writes, so a link tapped while the app runs reaches the running activity. You edit no AppDelegate, SceneDelegate, or MainActivity. The Flutter deep linking guide walks through each file, including switching off Flutter's own deep-linking flag so your router does not also receive the URL.

The deferred path runs from configure() straight to the attribution endpoint. A missing entitlement breaks tapped Universal Links while deferred matching keeps working, and a missing launch mode breaks warm start the same way. If your deferred link is fine but a tapped one is not, the native host setup is where to look.

Declare your own domain, if you serve links from one, so the SDK recognizes it on the first launch, before it has fetched your domain list and even with no network:

Your app's own subdomain on aplnk.to needs no declaration. A custom domain also needs applinks:links.yourapp.com in the Associated Domains and a second <data> host in the intent filter, and it must be verified and live in the dashboard.

The Manual Check and the Splash Gate

The automatic dispatch is right for most apps, with one exception: when your first screen depends on the answer and you would rather hold a splash than render onboarding and then yank it away. Disable the automatic deferred dispatch and await the check yourself. checkDeferredDeepLink() returns Future<WarpLinkDeepLink?>, resolving to null when there was no match.

Mount it around the navigator. The gate keeps the navigator mounted underneath the splash and draws the splash on top of it, so navigatorKey.currentState exists by the time the check resolves. A route pushed while the splash is up lands on the mounted navigator, and the splash lifts to reveal it. If the check resolves before the first frame, routeLink waits for that frame, as the routing section below shows:

Two properties of the check make this safe. It runs once per install: the native SDK caches the result once the check has definitively completed, and every later call returns that stored result with no network request, the same match if one was found and null if there was none. And an attempt that produced no usable answer, such as an offline first launch, is not recorded as complete, so it retries on the next launch instead of caching a permanent null.

The completion marker is install scoped on purpose. It is a backup excluded file in the app container on iOS and a file in noBackupFilesDir on Android, and no restore brings either back, so a reinstall attributes fresh. A separate device level marker outlives the uninstall (the Keychain on iOS, SharedPreferences on Android) and only tags the attribution request so a reinstall can be counted separately. Neither is readable from Dart.

Routing the Match

Here is the part that catches people. A router configuration maps URLs to screens, and a deferred match is not a URL: it is an object arriving from a native SDK after the app has already launched. Route the object yourself, through a navigator key, because the callback can fire before the first frame. On the first launch after an install, it usually does.

The /welcome screen receives the suggestion as a nullable string: the link's web destination when the match is soft, or null for a plain first-run experience. The product id comes off customParams, which is a Map<String, Object?> of JSON values, so it is untyped until you check it. The is String test is the check, and a link without the parameter falls through to the softer branches instead of failing. You can also parse deepLinkUrl, the link's own per-platform in-app URL. It is String?, so test for null first.

Three rules fall out of this code:

  • A guaranteed match can go straight to the content. matchGuaranteed is true only for a deterministic match, so a product id on a guaranteed match is safe to open. Treat it as a reliable hint, not an identity.
  • A probabilistic match earns public content only above 0.5. Below that, show a soft suggestion or the default first-run screen.
  • Below 0.3, ignore it. Show your normal onboarding and let the attribution do its work in the background.

If your app uses go_router, the shape is the same. Create the router once, outside any widget, and call it from the same post-frame guard. Keep Flutter's own deep-linking flag off so the router has one source:

Deferred matching is the hardest thing in a linking stack to test, because every shortcut you would normally take invalidates the test. The rule underneath all of it: a deferred check fires once per install, on the genuine first launch.

Both platforms, first. Create a test link in the dashboard with a destination and, if you have one, an iOS and Android deep link URL. Turn on debugLogging: true in configure() so the native side traces what it did. Keep the gap between tapping the link and opening the installed app short, ideally inside an hour, both to stay inside the match window and to land in the top confidence band.

A reinstall counts as an install. The check is scoped to one install on both platforms, so deleting the app and installing it again runs it again. A hot restart, a rerun without uninstalling, or a new flutter run over the existing app is not a fresh install, and the check returns the stored result with no network request.

iOS. Install through TestFlight for the closest match to a store install. For an ordinary retest, delete the app and install it again with flutter run. For the first-install path rather than the reinstall path, erase the simulator (Device, then Erase All Content and Settings, or xcrun simctl erase) or use a device that has never run the app.

Android. Use an internal testing track install. That is the only route that produces a real Play Install Referrer, so it is the only way to test the deterministic path Android users actually get. A sideloaded build has no referrer and falls through to the fingerprint. That is still a useful test, since it exercises the path iOS uses, but do not read it as proof the referrer branch works.

First-Open Test, Step by Step

Run this once per platform. It keeps the test honest, because a deferred check only fires on a genuine first launch.

  1. Create the link. In the dashboard, create a link with a destination and, if the screen needs it, a custom parameter such as product_id set to 42.
  2. Build the right app. Use flutter run --release on a device, or a TestFlight or internal testing build, with debugLogging: true.
  3. Start from a clean install. Delete the app. A rerun or a hot restart does not reset the install marker.
  4. Tap the link on the test device, from Notes or Messages, in Safari or Chrome. Do not paste it into the address bar.
  5. Install the app and open it within the hour, on the same network you tapped from. Inside the first hour an iOS fingerprint match is capped at 0.85.
  6. Read the result. The onLink event arrives with isDeferred: true. If it does not, run the checks below before you change any code.

isAttributionComplete() returns false until the deferred check has definitively finished for this install. getAttributionResult() returns the stored result of that check without a new network call, and null means no match. Call logAttributionState() from a debug button, not at startup.

What a good result looks like. On Android through the Play track: matchType of deterministic, matchConfidence of 1.0, matchGuaranteed true. On iOS through TestFlight on a device that never had the app: matchType of probabilistic, matchGuaranteed false, and around 0.85 if you tested inside the hour. Get those two and the wiring is correct.

You can read the native logs while you test. On iOS, the Xcode console shows [WarpLink] First launch: collecting device signals for attribution, then either Deferred deep link matched: <linkId> or No deferred deep link match. On Android, run adb logcat -s WarpLink for the same lines.

Match Limitations to Plan For

Deferred matching is accurate where the platform gives it a referrer and a best guess where it does not. Design your first screen around these limits.

  • iOS first installs are probabilistic. The App Store passes no referrer, so the match is a fingerprint, matchGuaranteed is false, and the score falls from 0.85 inside the first hour as time passes.
  • The fingerprint identifies a network, not a device. Two phones behind one shared address with the same language and timezone share a bucket. WarpLink lowers the score for that case, and for carrier grade NAT.
  • A network change breaks a fingerprint match. Tapping on Wi-Fi and installing over mobile data gives the click and the install two different IP addresses, so the server cannot link them. This is the most common reason a correct integration looks broken in a manual test. The deferred deep links concept page describes the same limit.
  • The window is finite. The default match window is 6 hours per link, with a 24 hour ceiling. After that the result is null.
  • Android needs the Play referrer for a guaranteed match. A sideloaded build, a device without Google Play Services, or an expired referrer falls back to the same fingerprint. A Play referrer from an install that began more than seven days earlier is attributed but not delivered as a deferred link.
  • Delivery is at most once. A delivered link is never offered again. A process death while a delivery is in flight can lose that one delivery, and so can an onLink that throws before it navigates.
  • A match chooses a screen. It does not identify a user. Never sign in, restore a session, or show personal data from a deferred match alone.

Why Isn't It Firing?

Almost every report of "deferred deep linking does not work in Flutter" is one of these, and most are testing artifacts rather than bugs.

  • The navigator was not ready. The deferred dispatch runs from configure(), which you called before the first frame. A bare navigatorKey.currentState?.pushNamed(...) does nothing when currentState is still null, so the app sits on the home screen. Wait for addPostFrameCallback, as in the routing section.
  • You used an API key instead of an SDK key. They share the wl_live_ prefix and both pass the format check, but an API key cannot record installs. Deep links keep resolving, so the integration looks healthy while every attribution call is rejected. Create the credential under Keys, SDK keys.
  • You tested by rerunning or hot restarting. Neither is a fresh install. The completion marker survives both, so the check returns the stored result with no network request. Uninstall the app first.
  • The match window expired. Once the click has aged out, checkDeferredDeepLink() returns null with nothing wrong anywhere. The default is 6 hours, set per link in the dashboard, with a 24 hour ceiling.
  • The network changed between the tap and the install. A different Wi-Fi, a VPN, or mobile data gives the install a new IP address, so a fingerprint match cannot form.
  • You expected the launch link to carry it. It never will. A deferred match does not travel as a URL, so it appears in neither getInitialDeepLink() nor your router's route table.
  • Offline on first launch. The check fails with E_NETWORK_ERROR, which reaches onLink as an ErrorEvent, and the attempt is deliberately not consumed, so it retries on the next launch. Treat the error as a report, not something to act on. An offline first launch does show your default onboarding.
  • You got the same match twice. That is the stored result at work. The manual checkDeferredDeepLink() always returns the stored result, while the automatic dispatch through onLink delivers a match at most once. Route from one or the other.
  • The iOS build fails to find the package. Flutter 3.38 to 3.43 need flutter config --enable-swift-package-manager, then flutter clean and a rebuild.
  • UnsupportedError on web or desktop. The plugin supports iOS and Android only. Guard the call with defaultTargetPlatform and kIsWeb.

If the link fires but the app opens a browser instead of your screen, that is Universal Link or App Link routing rather than deferred matching, and it is diagnosed against the association files.

Frequently Asked Questions

Does deferred deep linking in Flutter need native code? No. The plugin wraps the native iOS and Android SDKs, registers itself with Flutter's life cycles, and starts the deferred check from configure(). You add an Associated Domains entry on iOS, an intent filter on Android, and set minSdk to 26. Those serve tapped links. The deferred check itself runs from configure() straight to the attribution endpoint.

Do I need to call checkDeferredDeepLink myself in Flutter? No. configure() fires the check automatically and delivers a match through onLink with isDeferred set to true. Call checkDeferredDeepLink() yourself only when you set automaticDeferredDeepLinks: false, or when you want to hold a splash until the result is known. Omitting onLink does not switch the check off, because that request is what attributes the install.

Why does nothing happen on the first launch after install? The usual causes are a navigator that did not exist yet when the callback fired, an API key used where an SDK key was required, a rerun that was not a fresh install, and a store gap longer than the match window. Wait for the first frame before you navigate, check that the key came from Keys then SDK keys in the dashboard, uninstall the app before each test, and keep the click to install gap short.

Why is Android deferred matching more accurate than iOS? The Play Store passes the click referrer through the install, so Android gets a deterministic match with confidence 1.0 and matchGuaranteed true. The App Store passes nothing equivalent, so a genuine first install on iOS is matched by a probabilistic fingerprint that decays from 0.85 inside the first hour. The same Dart handles both, but the confidence you get back differs by platform.

How do I test Flutter deferred deep links without publishing to the stores? Uninstall the app, tap the link in the device browser, then install again with flutter run and open it. That exercises the fingerprint path, which is the path iOS uses. To exercise the real Play Install Referrer on Android, install through an internal testing track, because a sideloaded build has no referrer. On iOS, TestFlight is the closest match to a store install.

Does deferred deep linking need the IDFA or an App Tracking Transparency prompt? No. The iOS side uses the IDFV, which is vendor scoped and exempt from App Tracking Transparency, plus a fingerprint the server computes from the request. The SDK never touches the IDFA or the Google Advertising ID, so it adds no permission dialog to your first launch on either platform.

Sources

Everything above is the mechanism, and the mechanism does not change depending on who runs it. What a service has to provide are the two halves you cannot host inside your app: the click recorder at the edge that captures signals before the store takes over, and the attribution endpoint that computes the fingerprint from the request IP and walks the match cascade. WarpLink's Flutter SDK is the bridge between those and your Dart, and it collapses to WarpLink.configure(apiKey:, onLink:), one callback, and an isDeferred check. It is MIT licensed with no third party runtime dependencies, and it wraps the same native iOS and Android SDKs that native apps use, so attribution in a Flutter app behaves exactly as it does in a native one.

The part worth saying plainly is that deferred deep linking is install attribution. The same match that routes the user also tells you which link, which campaign, and which share drove the install, because linkId, deepLinkUrl, matchType, matchConfidence, and matchGuaranteed come back in the same payload. Route the user and attribute the install in one call. That is the bridge from the linking pillar to the attribution pillar, and from there to the real time analytics that show which taps became users, on iOS and Android, from one codebase.

Create a free WarpLink account and get deep linking, install attribution, and analytics in one SDK, with 10,000 clicks a month on the free tier and no time limit. The Flutter SDK docs have the full setup.

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.

Related Posts