Docs

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.

FieldTypeMeaning
routeStringWhere to navigate: the link's path when resolved, otherwise the raw URL's path and query.
uriUriThe URL that opened the app, exactly as the OS delivered it.
isResolvedboolWhether Lander recognized the link (linkId != null).
linkIdString?Lander link ID (a UUID). Pass it to trackEvent to attribute conversions.
urlString?Canonical short link, for example https://acme.golander.link/p-123.
pathString?The in app route set on the link, for example /product/123.
paramsMap<String, Object>Link params with the opened URL's query parameters merged over them. Values are String, num or bool.
campaign, channelString?Attribution labels set on the link.
isDeferredboolTrue when the link was recovered after an install or handed off after sign in.
confidenceLanderConfidencedeterministic, probabilistic or unattributed.
methodString?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.

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.

SituationWhat happenedHow the plugin gets itTypical method
Cold startThe 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 startThe app was running or in the background.iOS: scene(_:continue:), scene(_:openURLContexts:) or the app delegate. Android: onNewIntent.universal_link or app_link
DeferredThe 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:

LanderConfidenceWhenWhat to do with it
deterministicA 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.
probabilisticOpt 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.
unattributedLander 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 onDeepLink are 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 before initialize.
  • 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.

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:

lib/main.dart
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:

SituationWaits for
The app was opened by a linkThe resolve request (POST /v1/sdk/resolve)
First launch, no linkThe launch report (POST /v1/sdk/init), because a deferred link may come back
Any later launch without a linkNothing. 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.

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:

CaseWhy
Lander answers 404 link_not_foundThe 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 410The link is disabled (link_disabled) or past its expiry (link_expired).
The request failedOffline, timeout or a server error after retries.
Custom scheme URLacmeshop://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:

uriroute
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.

lib/app.dart
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:

  • go or push. router.go replaces the stack with the route's stack (/ then /product/123, so back goes home). router.push stacks the screen on top of wherever the user is. go is the safer default for links.
  • Pass the link as extra. The screen can then read link.params, link.campaign or link.linkId (to attribute a purchase with trackEvent).
  • Query parameters. If a link's path carries a query string (/search?q=shoes), go_router exposes it as state.uri.queryParameters.

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.

lib/app.dart
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.

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;

Next steps

On this page