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.
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.isDeferredistrueandmatchGuaranteedtells you whether the match was deterministic or a guess. Withwarplink_flutter1.1.0 that is oneWarpLink.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.
flutter pub add warplink_flutter
// lib/main.dart
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:warplink_flutter/warplink_flutter.dart';
void main() => runApp(const App());
class App extends StatefulWidget {
const App({super.key});
@override
State<App> createState() => _AppState();
}
class _AppState extends State<App> {
@override
void initState() {
super.initState();
unawaited(
WarpLink.configure(
apiKey: 'wl_live_yoursdkkeyhere000000000000000000', // an SDK key, from Keys > SDK keys
debugLogging: kDebugMode,
onLink: (event) {
switch (event) {
case LinkEvent(:final deepLink) when deepLink.isDeferred:
// First open after install. matchGuaranteed is false for an iOS fingerprint match.
debugPrint(
'${deepLink.linkId} ${deepLink.matchType?.name} ${deepLink.matchConfidence}',
);
// Route it once the navigator exists (see the routing section).
case LinkEvent():
// A tap on a link while the app was installed.
break;
case ErrorEvent(:final error):
debugPrint('WarpLink ${error.wireCode}: ${error.message}');
}
},
),
);
}
@override
Widget build(BuildContext context) => const MaterialApp(home: Placeholder());
}
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:
| Property | Type | Meaning |
|---|---|---|
linkId | String | Identifier of the link in the dashboard |
destination | String | The web destination URL |
deepLinkUrl | String? | The in-app URL for this OS, or null when the link has none |
customParams | Map<String, Object?> | Custom key and value pairs set on the link, empty when none |
isDeferred | bool | true for an install match, false for a tap |
matchType | MatchType? | MatchType.deterministic, MatchType.probabilistic, or null |
matchConfidence | double? | Confidence from 0 to 1, or null when not matched |
matchGuaranteed | bool | true 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 path | How the match is made | matchType | matchConfidence | matchGuaranteed |
|---|---|---|---|---|
| Android, installed from the Play Store | The Play Install Referrer names the click | deterministic | 1.0 | true |
| Android, sideloaded or no Google Play Services | Fingerprint: IP address, language, timezone | probabilistic | Up to 0.85 inside the first hour | false |
| iOS, first install from the App Store | Fingerprint: IP address, language, timezone | probabilistic | Up to 0.85 inside the first hour | false |
| iOS, the same install asks again | The IDFV | deterministic | 1.0 | true |
| Either platform, click older than the match window | No match | null result | n/a | n/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 click | enriched_tz | enriched | basic |
|---|---|---|---|
| < 1 hour | 0.85 | 0.80 | 0.70 |
| < 3 hours | 0.65 | 0.60 | 0.50 |
| < 6 hours | 0.50 | 0.45 | 0.35 |
| < 24 hours | 0.30 | 0.25 | 0.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.
flutter pub add warplink_flutter
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:
flutter config --enable-swift-package-manager
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:
android {
defaultConfig {
minSdk = 26
}
}
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:
// lib/warplink_setup.dart
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:warplink_flutter/warplink_flutter.dart';
import 'deferred_navigation.dart';
void startWarpLink() {
unawaited(
WarpLink.configure(
apiKey: 'wl_live_yoursdkkeyhere000000000000000000',
onLink: (event) {
switch (event) {
case LinkEvent(:final deepLink):
// Cold start, warm start, or a deferred install match.
routeLink(deepLink);
case ErrorEvent(:final error):
debugPrint('WarpLink ${error.wireCode}: ${error.message}');
}
},
),
);
}
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:
await WarpLink.configure(
apiKey: 'wl_live_yoursdkkeyhere000000000000000000',
automaticDeepLinks: false, // wire onDeepLink / getInitialDeepLink yourself
automaticDeferredDeepLinks: false, // call checkDeferredDeepLink 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:
await WarpLink.configure(
apiKey: 'wl_live_yoursdkkeyhere000000000000000000',
linkDomains: ['links.yourapp.com'],
onLink: (event) {
if (event case LinkEvent(:final deepLink)) routeLink(deepLink);
},
);
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.
// lib/splash_gate.dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:warplink_flutter/warplink_flutter.dart';
import 'deferred_navigation.dart';
class SplashGate extends StatefulWidget {
const SplashGate({super.key, required this.child});
final Widget child;
@override
State<SplashGate> createState() => _SplashGateState();
}
class _SplashGateState extends State<SplashGate> {
bool _ready = false;
@override
void initState() {
super.initState();
unawaited(_check());
}
Future<void> _check() async {
try {
await WarpLink.configure(
apiKey: 'wl_live_yoursdkkeyhere000000000000000000',
automaticDeferredDeepLinks: false,
onLink: (event) {
if (event case LinkEvent(:final deepLink)) routeLink(deepLink);
},
);
final link = await WarpLink.checkDeferredDeepLink();
if (link != null && link.isDeferred) {
routeLink(link);
}
} on WarpLinkException catch (error) {
// Offline on first launch: the attempt is not consumed, so the SDK
// retries on the next launch. Show the default experience now.
debugPrint('Deferred deep link check failed: ${error.message}');
} finally {
if (mounted) {
setState(() => _ready = true);
}
}
}
@override
Widget build(BuildContext context) => Stack(
fit: StackFit.expand,
children: <Widget>[
widget.child,
if (!_ready)
const ColoredBox(
color: Colors.black,
child: Center(child: CircularProgressIndicator()),
),
],
);
}
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:
// lib/gated_app.dart
import 'package:flutter/material.dart';
import 'deferred_navigation.dart';
import 'splash_gate.dart';
class GatedApp extends StatelessWidget {
const GatedApp({super.key});
@override
Widget build(BuildContext context) => MaterialApp(
navigatorKey: navigatorKey,
builder: (context, navigator) => SplashGate(child: navigator!),
routes: <String, WidgetBuilder>{
'/': (context) => const Placeholder(), // your home screen
'/product': (context) => const Placeholder(), // your product screen
'/welcome': (context) => const Placeholder(), // your onboarding screen
},
);
}
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.
// lib/deferred_navigation.dart
import 'package:flutter/widgets.dart';
import 'package:warplink_flutter/warplink_flutter.dart';
final navigatorKey = GlobalKey<NavigatorState>();
typedef LinkRoute = ({String name, Object? arguments});
/// Picks a screen from the match. Identity work needs a guarantee, not a high
/// score, and this function only chooses content.
LinkRoute chooseRoute(WarpLinkDeepLink link) {
final productId = link.customParams['product_id'];
final product = productId is String && productId.isNotEmpty ? productId : null;
final confidence = link.matchConfidence ?? 0;
if (product != null && (link.matchGuaranteed || confidence > 0.5)) {
return (name: '/product', arguments: product);
}
if (confidence > 0.3) {
return (name: '/welcome', arguments: link.destination);
}
return (name: '/welcome', arguments: null);
}
void routeLink(WarpLinkDeepLink link) {
final route = chooseRoute(link);
void push() => navigatorKey.currentState
?.pushNamed(route.name, arguments: route.arguments);
if (navigatorKey.currentState != null) {
push();
return;
}
// The navigator has not been built yet. Wait for the first frame.
WidgetsBinding.instance.addPostFrameCallback((_) => push());
}
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.
matchGuaranteedistrueonly 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:
// lib/deferred_go_router.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:warplink_flutter/warplink_flutter.dart';
final rootNavigatorKey = GlobalKey<NavigatorState>();
final GoRouter router = GoRouter(
navigatorKey: rootNavigatorKey,
routes: <RouteBase>[
GoRoute(path: '/', builder: (context, state) => const Placeholder()),
GoRoute(
path: '/product/:id',
builder: (context, state) => Text('Product ${state.pathParameters['id']}'),
),
GoRoute(
path: '/welcome',
builder: (context, state) => Text('Welcome ${state.extra ?? ''}'),
),
],
);
void routeWithGoRouter(WarpLinkDeepLink link) {
final productId = link.customParams['product_id'];
final confidence = link.matchConfidence ?? 0;
void go() {
if (productId is String && (link.matchGuaranteed || confidence > 0.5)) {
router.go('/product/$productId');
} else {
router.go('/welcome', extra: confidence > 0.3 ? link.destination : null);
}
}
if (rootNavigatorKey.currentContext != null) {
go();
return;
}
// The first frame has not mounted the router yet.
WidgetsBinding.instance.addPostFrameCallback((_) => go());
}
Testing Deferred Deep Links on Each Platform
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.
- Create the link. In the dashboard, create a link with a destination and, if the screen needs it, a custom parameter such as
product_idset to42. - Build the right app. Use
flutter run --releaseon a device, or a TestFlight or internal testing build, withdebugLogging: true. - Start from a clean install. Delete the app. A rerun or a hot restart does not reset the install marker.
- Tap the link on the test device, from Notes or Messages, in Safari or Chrome. Do not paste it into the address bar.
- 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.
- Read the result. The
onLinkevent arrives withisDeferred: true. If it does not, run the checks below before you change any code.
// lib/attribution_state.dart
import 'package:flutter/foundation.dart';
import 'package:warplink_flutter/warplink_flutter.dart';
Future<void> logAttributionState() async {
debugPrint('native SDK version: ${await WarpLink.sdkVersion()}'); // 1.1.0
debugPrint('configured: ${await WarpLink.isConfigured()}');
debugPrint('check complete: ${await WarpLink.isAttributionComplete()}');
final result = await WarpLink.getAttributionResult();
debugPrint(
result == null
? 'attribution: no match'
: 'attribution: ${result.linkId} ${result.matchType.name} '
'${result.matchConfidence} guaranteed=${result.matchGuaranteed}',
);
}
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,
matchGuaranteedisfalse, 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
onLinkthat 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 barenavigatorKey.currentState?.pushNamed(...)does nothing whencurrentStateis stillnull, so the app sits on the home screen. Wait foraddPostFrameCallback, 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()returnsnullwith 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 reachesonLinkas anErrorEvent, 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 throughonLinkdelivers 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, thenflutter cleanand a rebuild. UnsupportedErroron web or desktop. The plugin supports iOS and Android only. Guard the call withdefaultTargetPlatformandkIsWeb.
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
- Flutter, Deep linking: the framework's own handler, which reports only a URL the OS delivered, which is why a fresh install has nothing to report.
- Android Developers, Play Install Referrer: the referrer the Play Store carries through an install.
- Apple, identifierForVendor: the vendor-scoped identifier the iOS side uses instead of the IDFA.
- Flutter, addPostFrameCallback: the callback used to wait for the first frame before navigating.
- Flutter, Swift Package Manager for app developers: how to turn on Swift Package Manager for the iOS side of a plugin.
Related Guides
- Each platform in depth: Deferred Deep Linking on iOS and Deferred Deep Linking on Android go further into the IDFV, Private Relay, and the Play Install Referrer than a cross platform post can.
- The setup underneath this one: Flutter Deep Linking covers the entitlement, the intent filter, Flutter's own deep-linking flag, and go_router wiring for tapped links.
- Start here for the concepts: The Complete Deep Linking Guide for Mobile Developers.
- When a tapped link misbehaves: Universal Links Not Opening? for iOS, and Android App Links autoVerify Failed for the Android association file.
- The same guide for another framework: Deferred Deep Linking in React Native covers the same match cascade from TypeScript.
- The short version of the mechanism: the deferred deep linking page walks the tap, the store, and the first launch in three steps, on both platforms at once.
- Reference: the Flutter deferred deep links docs, the SDK setup guide, the attribution docs, and the deferred deep links concept page.
- Choosing an SDK: 5 Deferred Deep Linking SDKs Compared sets the vendors' first-open matching, platform coverage, and published pricing side by side.
How WarpLink Helps
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
Flutter Deep Linking: Universal Links and App Links Setup Guide
Flutter deep linking takes four pieces: Universal Links on iOS, App Links on Android, Flutter's own link flag, and a safe cold start. Set up each end to end.
Deferred Deep Linking on Android: How to Implement It in Kotlin
Deferred deep linking on Android hands the tapped link to the app on first launch through the Play Install Referrer, with fingerprint matching as the fallback.
Deferred Deep Linking in React Native: One API for iOS and Android
Deferred deep linking React Native guide: route the first open after install with one WarpLink 1.1.0 call, plus first-open test steps and match limits.