Receiving links
Listen to Lander.onDeepLink, understand cold start, warm start and deferred delivery, and route with go_router or Navigator.
Every link that opens your app reaches you as a LanderLink on one stream, Lander.onDeepLink. You listen once, at the root of your app, and send each link to your router.
The LanderLink
| Field | Type | Meaning |
|---|---|---|
route | String | Where to navigate: the link's path when resolved, otherwise the raw URL's path and query. |
uri | Uri | The URL that opened the app, exactly as the OS delivered it. |
isResolved | bool | Whether Lander recognized the link (linkId != null). |
linkId | String? | Lander link ID (a UUID). Pass it to trackEvent to attribute conversions. |
url | String? | Canonical short link, for example https://acme.golander.link/p-123. |
path | String? | The in app route set on the link, for example /product/123. |
params | Map<String, Object> | Link params with the opened URL's query parameters merged over them. Values are String, num or bool. |
campaign, channel | String? | Attribution labels set on the link. |
isDeferred | bool | True when the link was recovered after an install or handed off after sign in. |
confidence | LanderConfidence | deterministic, probabilistic or unattributed. |
method | String? | How the link was tied to the open: universal_link, app_link, install_referrer, landing_page, authenticated_handoff or probabilistic_match. |
Full details are in the API reference.
Listen to onDeepLink
final sub = Lander.onDeepLink.listen((link) {
router.go(link.route, extra: link);
});Subscribe once, in the initState of your root widget after the router exists, and cancel in dispose. Complete examples for go_router and Navigator are below.
Cold start, warm start and deferred
A link can reach your app in three situations. All three arrive on the same stream.
| Situation | What happened | How the plugin gets it | Typical method |
|---|---|---|---|
| Cold start | The app was not running. The tap launched it. | iOS: scene connection options or the app delegate. Android: the launch intent. | universal_link or app_link |
| Warm start | The app was running or in the background. | iOS: scene(_:continue:), scene(_:openURLContexts:) or the app delegate. Android: onNewIntent. | universal_link or app_link |
| Deferred | The app was not installed. The person installed it from the store and opened it for the first time. | No URL at all: Lander returns the link in its answer to the first launch report (POST /v1/sdk/init). | install_referrer or probabilistic_match |
Cold start and deferred links are both "the link that launched the app", so they are delivered once, either on the stream or through getInitialLink(). Warm start links always go to the stream.
If the app was opened by a link on its very first launch, that link wins and no deferred link is delivered on top of it.
The iOS Continue in app page sits in between: after installing, the person taps "open it" on the page, so the link arrives as an ordinary cold start open, but with method: landing_page and isDeferred: true when the app was installed after the click.
Confidence levels
Every link carries a confidence, so you can decide how much to trust it:
LanderConfidence | When | What to do with it |
|---|---|---|
deterministic | A tap on a Universal Link or App Link, an Android install referrer token, the iOS Continue in app page or an authenticated handoff. Lander knows. | Treat it as if the user tapped it in your app. |
probabilistic | Opt in iOS probabilistic matching guessed the link from coarse signals. Usually right, sometimes wrong. | Show the screen, but never sign someone in, apply a credit or reveal anything private because of it. |
unattributed | Lander could not tie a link to this open. Unresolved links always have this. | Route on the raw URL if you want; there is no Lander data. |
More on how each one is decided in Attribution and Deferred links.
Buffered, exactly once delivery
You never have to race the SDK at startup:
- Buffered. Links that arrive while nothing listens to
onDeepLinkare kept and replayed, in order, to the next listener. This covers cold starts and links that arrive before your router is ready. You can even listen beforeinitialize. - The launch link is delivered once. It goes to the stream unless
getInitialLink()claims it first. Whichever gets it first wins, so navigation never happens twice. - In arrival order. Links are resolved one after another, so they come out in the order they were tapped.
- Duplicates are dropped. The same URL reported twice within two seconds counts as one open (some OS versions report a launch URL through more than one callback).
- Recents is not a new open. On Android, reopening the app from Recents replays the original launch intent; the plugin ignores it.
- Opens are reported for you. Lander records the open when the SDK resolves the link. You do not need to track opens yourself.
The stream is a broadcast stream: every active listener gets live links. Buffered links go to the first listener that subscribes, so keep a single listener at the root of your app. If you cancel and subscribe again later, links that arrived in between are buffered and replayed.
getInitialLink: pick the first screen
Most apps only need the stream. Use getInitialLink() when you want to choose the router's first location from the launch link, for example behind a splash screen:
Future<void> main() async {
await Lander.initialize(apiKey: 'pk_live_...');
final initial = await Lander.getInitialLink(); // waits for the network, see below
runApp(MyApp(initialLocation: initial?.route ?? '/'));
}Once getInitialLink() has been called, the stream never delivers the launch link. If the stream already delivered it, getInitialLink() returns null.
getInitialLink() waits for the network when it has to:
| Situation | Waits for |
|---|---|
| The app was opened by a link | The resolve request (POST /v1/sdk/resolve) |
| First launch, no link | The launch report (POST /v1/sdk/init), because a deferred link may come back |
| Any later launch without a link | Nothing. Returns null right away. |
Each request attempt has a 10 second timeout and failed requests are retried, so on a bad network this can take a while. Listening to onDeepLink never blocks anything, which is why it is the recommended path.
Unresolved links
Not every URL is a Lander link, and the network is not always there. The SDK still delivers the link, with isResolved == false, confidence == unattributed and only uri set:
| Case | Why |
|---|---|
Lander answers 404 link_not_found | The URL is not a link of this project and environment: a page on your own site that your app also claims, a nested path, a typo, or a live link opened by an app using a pk_test_ key. |
Lander answers 410 | The link is disabled (link_disabled) or past its expiry (link_expired). |
| The request failed | Offline, timeout or a server error after retries. |
| Custom scheme URL | acmeshop://product/123 is delivered as is, with no network call. |
route still works for unresolved links. It uses the URL's path and query; for custom scheme URLs the host counts as the first path segment:
uri | route |
|---|---|
acmeshop://product/123 | /product/123 |
acmeshop://product/123?color=red | /product/123?color=red |
https://acme.golander.link/old-slug (unknown) | /old-slug |
So design your router to cope with routes it does not know (a not found screen, or a redirect home).
One exception: a custom scheme URL that carries a short link in a lander_link query parameter (the iOS Continue in app page builds these) is resolved like the short link itself. uri stays the scheme URL, and if resolving fails the scheme URL's own path still routes.
Other plugins' URLs
The iOS plugin never consumes a URL, so sign in redirects and other plugins' custom scheme URLs also reach onDeepLink. Ignore routes you do not handle.
Route with go_router
This is the pattern used by the Acme Shop example app. The router is created once, and the subscription forwards every link to it.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:lander_flutter/lander_flutter.dart';
GoRouter buildRouter() => GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) => const HomeScreen(),
routes: [
GoRoute(
// https://acme.golander.link/p-123 resolves to /product/123.
path: 'product/:id',
builder: (context, state) => ProductScreen(
id: state.pathParameters['id']!,
link: state.extra is LanderLink ? state.extra as LanderLink : null,
),
),
],
),
],
// Unresolved links route on the raw URL, which may match nothing.
errorBuilder: (context, state) => NotFoundScreen(uri: state.uri),
);
class ShopApp extends StatefulWidget {
const ShopApp({super.key});
@override
State<ShopApp> createState() => _ShopAppState();
}
class _ShopAppState extends State<ShopApp> {
late final GoRouter _router = buildRouter();
StreamSubscription<LanderLink>? _links;
@override
void initState() {
super.initState();
// Subscribing once the router exists is enough: Lander buffers links
// (including the one that launched the app) until someone listens.
_links = Lander.onDeepLink.listen((link) {
_router.go(link.route, extra: link);
});
}
@override
void dispose() {
_links?.cancel();
_router.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => MaterialApp.router(
title: 'Acme Shop',
routerConfig: _router,
);
}A few choices to make:
goorpush.router.goreplaces the stack with the route's stack (/then/product/123, so back goes home).router.pushstacks the screen on top of wherever the user is.gois the safer default for links.- Pass the link as
extra. The screen can then readlink.params,link.campaignorlink.linkId(to attribute a purchase withtrackEvent). - Query parameters. If a link's path carries a query string (
/search?q=shoes), go_router exposes it asstate.uri.queryParameters.
Links that need sign in
If a link points at a screen that needs an account, hold on to it and open it after sign in:
LanderLink? _pending;
void _open(LanderLink link) {
if (!auth.isSignedIn) {
_pending = link; // open it once the user has signed in
_router.go('/login');
return;
}
_router.go(link.route, extra: link);
}
// After a successful sign in:
void _onSignedIn() {
final link = _pending;
_pending = null;
_router.go(link?.route ?? '/', extra: link);
}Here auth stands for your own auth state. Signing in is also the moment to call Lander.claimHandoff if you use handoffs.
Route with Navigator
Without a routing package, give MaterialApp a navigatorKey and an onGenerateRoute, and push each link's route by name.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:lander_flutter/lander_flutter.dart';
final navigatorKey = GlobalKey<NavigatorState>();
class ShopApp extends StatefulWidget {
const ShopApp({super.key});
@override
State<ShopApp> createState() => _ShopAppState();
}
class _ShopAppState extends State<ShopApp> {
StreamSubscription<LanderLink>? _links;
@override
void initState() {
super.initState();
_links = Lander.onDeepLink.listen(_open);
}
void _open(LanderLink link) {
final navigator = navigatorKey.currentState;
if (navigator == null) {
// The first frame is not built yet: try again right after it.
WidgetsBinding.instance.addPostFrameCallback((_) => _open(link));
return;
}
navigator.pushNamed(link.route, arguments: link);
}
@override
void dispose() {
_links?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) => MaterialApp(
title: 'Acme Shop',
navigatorKey: navigatorKey,
onGenerateRoute: _route,
onUnknownRoute: (settings) => MaterialPageRoute(
builder: (_) => NotFoundScreen(name: settings.name),
),
);
}
/// Maps a route such as /product/123 to a screen. Returns null for routes the
/// app does not know, which sends them to onUnknownRoute.
Route<void>? _route(RouteSettings settings) {
final uri = Uri.parse(settings.name ?? '/');
final link = settings.arguments is LanderLink ? settings.arguments as LanderLink : null;
final Widget? page = switch (uri.pathSegments) {
[] => const HomeScreen(),
['product', final id] => ProductScreen(id: id, link: link),
_ => null,
};
if (page == null) return null;
return MaterialPageRoute(settings: settings, builder: (_) => page);
}The MaterialApp starts at /, so onGenerateRoute builds the home screen first and each link is pushed on top of it.
Read the link's data
Params set on the link in Lander, plus any query parameters on the opened URL, are in link.params. Query parameters win when a key is in both, and arrive as strings.
final productId = link.params['productId'] as String?;
final discount = link.params['discount']; // String, num or bool
if (link.campaign == 'summer_2026') {
// Show the campaign banner.
}For unresolved links params is empty. Read the raw URL instead:
final query = link.uri.queryParameters;