Flutter Deep Links
Handle deep links in your Flutter app across iOS and Android, covering both cold start and warm start with the WarpLink SDK.
Deep links arrive in two scenarios: cold start (app launched by a link) and warm start (app brought from background by a link). The onLink callback you pass to configure() handles both. You do not need to wire up listeners in your widgets.
Basic Setup
Call configure() once from your root widget's initState and register onLink there (see Flutter SDK). Cold start, warm start, and deferred installs all flow through it:
import 'package:flutter/material.dart';
import 'package:warplink_flutter/warplink_flutter.dart';
class _AppState extends State<App> {
@override
void initState() {
super.initState();
WarpLink.configure(
apiKey: 'wl_live_yoursdkkeyhere000000000000000000',
onLink: (event) {
if (event is! LinkEvent) return;
// Cold start, warm start, and deferred installs all arrive here.
final deepLink = event.deepLink;
navigateTo(deepLink.deepLinkUrl ?? deepLink.destination);
},
);
}
}
With a Navigator Key
A link that launches the app can arrive before the first frame, when no navigator exists yet. Create a navigator key once and have navigateTo wait for the navigator before it pushes a route:
import 'package:flutter/material.dart';
final navigatorKey = GlobalKey<NavigatorState>();
void navigateTo(String url) {
final navigator = navigatorKey.currentState;
if (navigator == null) {
WidgetsBinding.instance.addPostFrameCallback((_) => navigateTo(url));
return;
}
navigator.pushNamed('/product', arguments: url);
}
Pass navigatorKey to MaterialApp(navigatorKey: navigatorKey, ...). The same pattern works with a router package: hold its router object where navigateTo can reach it and call go or push there once it exists.
Routing with go_router
Use one setup: Flutter's own deep-linking flag off (see Flutter's Built-In Link Handling), and onLink drives navigation. Create the router once, outside any widget, with its own navigator key, and call configure() from the initState of your root widget. A link that launches the app can arrive before the router is mounted, so goTo waits for it:
import 'dart:async';
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()), // your home screen
GoRoute(
path: '/product/:id',
builder: (context, state) => Scaffold(
body: Center(child: Text('Product ${state.pathParameters['id']}')),
), // your product screen
),
],
);
void goTo(String location) {
if (rootNavigatorKey.currentContext != null) {
router.go(location);
return;
}
// The first frame has not mounted the router yet.
WidgetsBinding.instance.addPostFrameCallback((_) => router.go(location));
}
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',
onLink: (event) {
switch (event) {
case LinkEvent(:final deepLink):
final productId = deepLink.customParams['product_id'];
goTo(productId is String ? '/product/$productId' : '/');
case ErrorEvent(:final error):
debugPrint('WarpLink ${error.wireCode}: ${error.message}');
}
},
),
);
}
@override
Widget build(BuildContext context) =>
MaterialApp.router(routerConfig: router);
}
Put the route in a link's custom parameters in the dashboard, for example product_id, and your app decides where it goes. That keeps your URL structure out of the short link. A deferred match on the first launch arrives the same way.
Cold Start vs Warm Start
| Scenario | Handled by | When |
|---|---|---|
| Cold start | onLink (automatic) | App was not running: launched by tapping a link |
| Warm start | onLink (automatic) | App was in background: brought to foreground by a link |
Both are delivered to onLink automatically. To drive them yourself, set automaticDeepLinks: false and use getInitialDeepLink() (cold start) and onDeepLink (warm start).
How the Plugin Works
warplink_flutter is a thin Dart layer over the native WarpLink iOS and Android SDKs. The same link rules apply on Flutter as in a native app, and there is no host code to add: no AppDelegate forwarding call on iOS and no Activity change on Android. This is the difference from some other cross-platform SDKs.
One Delivery per Tap
Each tap on a link produces one delivery. With automaticDeepLinks on, that delivery goes to onLink. An explicit onDeepLink subscriber is separate: it receives every link event, so use it only when you drive handling yourself.
Known Limits
- Delivery is at most once. A process death or engine disposal while a tap's final step is in flight can lose that one delivery, and so can an
onLinkthat throws before it navigates. The SDK never offers a delivered link again. - On iOS, another plugin that handles a cold-start link first can hide it from WarpLink. Check the plugins that register for incoming links if a cold-start link never reaches
onLink.
Manual Setup (Advanced)
With automaticDeepLinks: false, wire the stream yourself in your root widget:
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:warplink_flutter/warplink_flutter.dart';
class _AppState extends State<App> {
StreamSubscription<WarpLinkEvent>? _links;
@override
void initState() {
super.initState();
_links = WarpLink.onDeepLink.listen((event) {
switch (event) {
case LinkEvent(:final deepLink):
navigateTo(deepLink.destination);
case ErrorEvent(:final error):
debugPrint('Deep link error: ${error.message}');
}
});
WarpLink.getInitialDeepLink().then((link) {
if (link != null) navigateTo(link.destination);
});
}
@override
void dispose() {
_links?.cancel();
super.dispose();
}
}
getInitialDeepLink() takes the URL that launched the app once and resolves it. The second call returns null. With automaticDeepLinks and an onLink callback, the launch link goes to onLink and getInitialDeepLink() returns null.
Working with Deep Link Data
final link = await WarpLink.handleDeepLink('https://aplnk.to/abc123');
if (link != null) {
debugPrint('Link ID: ${link.linkId}');
debugPrint('Destination: ${link.destination}');
// Platform-specific deep link URL
final deepLinkUrl = link.deepLinkUrl;
if (deepLinkUrl != null) {
navigateToPath(deepLinkUrl);
}
// Custom parameters
final productId = link.customParams['product_id'] as String?;
if (productId != null) {
showProduct(productId);
}
}
handleDeepLink is a manual call. It is never superseded by an automatic tap.
Error Handling
WarpLinkException is a sealed class, so a switch over its subclasses is exhaustive:
try {
final link = await WarpLink.handleDeepLink(url);
} on WarpLinkException catch (error) {
switch (error) {
case WarpLinkNotConfiguredException():
debugPrint('Call configure() first');
case WarpLinkInvalidUrlException():
debugPrint('Not a WarpLink URL');
case WarpLinkLinkNotFoundException():
showLinkExpired();
case WarpLinkNetworkException():
showOfflineMessage();
default:
debugPrint('[${error.wireCode}] ${error.message}');
}
}
Each exception also exposes code, a WarpLinkErrorCode, and wireCode, the E_* string shared with the other SDKs.
Listener Events
The onDeepLink stream emits a sealed WarpLinkEvent. It is either a LinkEvent or an ErrorEvent:
WarpLink.onDeepLink.listen((event) {
switch (event) {
case LinkEvent(:final deepLink):
// Success: resolved deep link
debugPrint(deepLink.destination);
case ErrorEvent(:final error):
// Error: resolution failed
debugPrint('${error.wireCode}: ${error.message}');
}
});
onDeepLink emits every URL the app receives, resolved. A URL that is not a WarpLink link (a custom OAuth scheme, a link on a domain your org does not own) arrives as an ErrorEvent with a WarpLinkInvalidUrlException. The automatic onLink sink silently drops those. It is independent of onLink. Cancel the subscription to stop listening.
Troubleshooting
iOS: the app opens but onLink never fires. Check the Associated Domains capability on the Runner target and that the entry is applinks: followed by your exact app host. Then check that your own AppDelegate or SceneDelegate does not override application(_:continue:restorationHandler:), application(_:open:options:), or scene(_:openURLContexts:) without calling super. Flutter hands the URL to plugins through that chain, so an override that skips super stops it from reaching WarpLink.
Android: the app opens but a tap on a running app does nothing. Check android:launchMode on your main Activity in android/app/src/main/AndroidManifest.xml. The Flutter template sets singleTop. With standard, Android starts a fresh Activity for the incoming Intent instead of routing it to the running one, and the plugin never sees the URL. singleTask works too.
Android: the Gradle build fails with a manifest merge error about minSdkVersion. The native WarpLink SDK needs API 26. Set minSdk = 26 in android/app/build.gradle.kts.
My router shows an unknown-route page for a WarpLink URL. Flutter's own deep link handling also delivers the URL to your router. Turn it off with FlutterDeepLinkingEnabled set to false in Info.plist on iOS and flutter_deeplinking_enabled set to false inside your main <activity> on Android. See Flutter's Built-In Link Handling.
UnsupportedError when calling WarpLink. The plugin runs on iOS and Android only. Guard calls with defaultTargetPlatform if your app also targets web or desktop.
Deferred deep link check always returns null, but direct links resolve fine. Check which credential was passed to configure(). Both an API key and an SDK key match the wl_live_ + 32-character format, so a swapped key does not fail the format check and deep links keep working, but the deferred attribution request is rejected every time. Create a dedicated SDK key under Keys > SDK keys in the dashboard.
FAQ
Do I need to edit AppDelegate, SceneDelegate, or MainActivity? No. The plugin registers with Flutter's lifecycle on both platforms. The setup is the Associated Domains capability on iOS, and the intent filter plus minSdk = 26 on Android.
Can I use onDeepLink and the automatic onLink callback at the same time? Yes. They are independent. A URL the automatic path treats as foreign and drops is still delivered to an explicit onDeepLink subscriber as an ErrorEvent, since an explicit subscriber asked for every event rather than only the ones WarpLink resolves.
Why does a rapid double-tap on the same link only fire onLink once? Each tap produces one delivery to onLink, and a duplicate tap on the same link is not delivered twice.
Why does a link tapped twice in quick succession sometimes resolve twice anyway? Two taps on different links are two deliveries, so each one reaches onLink. Only a repeat tap on the same link counts as one.
Does configure() wait for the first link to be delivered? No. The future completes when native configuration completes. The cold-start link and the deferred check are delivered to onLink right after, in whichever order they finish.