Skip to main content

Receive Links in Flutter

Handle incoming deep links in your Flutter app using the ULink SDK's reactive streams.

Prerequisites

Before receiving links, ensure you've completed the SDK setup:

SDK Integration

  • Flutter SDK plugin added to your pubspec.yaml and installed
  • ULink.instance initialized with your API key and configuration
  • iOS platform configured (bundle ID, URL scheme, associated domains if using universal links)
  • Android platform configured (package name, intent filters, permissions)
  • Project settings configured in the ULink dashboard for both platforms
  • API key generated from Dashboard → Settings → API Keys → Generate API Key
  • Domain configured in your project (Domains → Add Domain)

If you haven't completed these steps, follow the Flutter Getting Started guide to set up the SDK and configure your project.

The ULink Flutter SDK exposes Dart streams that emit resolved link data when users tap your links. The SDK handles all the native platform integration automatically, so you just need to listen to the streams.

The SDK provides two separate streams for different link types:

Step 1.1: Set Up Stream Listeners

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_ulink_sdk/flutter_ulink_sdk.dart';
import 'package:flutter_ulink_sdk/models/models.dart';

class MyApp extends StatefulWidget {
const MyApp({super.key});


State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
final _sdk = ULink.instance;
StreamSubscription<ULinkResolvedData>? _dynamicSub;
StreamSubscription<ULinkResolvedData>? _unifiedSub;


void initState() {
super.initState();
_setupLinkListeners();
}


void dispose() {
_dynamicSub?.cancel();
_unifiedSub?.cancel();
super.dispose();
}

void _setupLinkListeners() {
// Listen for dynamic links (in-app handling)
_dynamicSub = _sdk.onDynamicLink.listen((linkData) {
_handleDynamicLink(linkData);
});

// Listen for unified links (marketing/redirect links)
_unifiedSub = _sdk.onUnifiedLink.listen((linkData) {
_handleUnifiedLink(linkData);
});
}

void _handleDynamicLink(ULinkResolvedData data) {
debugPrint('Dynamic link received:');
debugPrint(' Slug: ${data.slug}');
debugPrint(' Parameters: ${data.parameters}');

// Navigate based on parameters
if (data.parameters != null) {
final screen = data.parameters!['screen'] as String?;
if (screen != null) {
_navigateToScreen(screen, data.parameters);
}
}
}

void _handleUnifiedLink(ULinkResolvedData data) {
debugPrint('Unified link received:');
debugPrint(' Slug: ${data.slug}');
// Unified links typically don't have parameters
// Handle marketing/redirect links here
}

void _navigateToScreen(String screen, Map<String, dynamic>? parameters) {
// Your navigation logic
// For example, using Navigator or a routing package
}


Widget build(BuildContext context) {
return MaterialApp(
title: 'My App',
home: HomeScreen(),
);
}
}
Stream Types
  • onDynamicLink: Emits dynamic links with parameters for in-app navigation
  • onUnifiedLink: Emits unified links for marketing/redirect scenarios
  • Both streams emit ULinkResolvedData objects containing the resolved link information
Stream Cleanup

Always cancel stream subscriptions in dispose() to prevent memory leaks. The SDK uses broadcast streams, so multiple listeners can subscribe to the same stream.

The SDK provides methods to access links that arrived before your app was ready or after a cold start.

When your app launches from a link, you can retrieve the initial link data:

Future<void> _checkInitialLink() async {
final initialLink = await _sdk.getInitialDeepLink();
if (initialLink != null) {
debugPrint('App launched from link: ${initialLink.slug}');
// Navigate to the appropriate screen
_handleDynamicLink(initialLink);
}
}

Retrieve the last resolved link data (useful for resuming state after app restarts):

Future<void> _checkLastLink() async {
final lastLink = await _sdk.getLastLinkData();
if (lastLink != null) {
debugPrint('Last link: ${lastLink.slug}');
// Use lastLink to restore app state
_handleDynamicLink(lastLink);
}
}
Persistence Required

getLastLinkData() only emits data if you initialized the SDK with persistLastLinkData: true in ULinkConfig (the Flutter default is false). Enable it when you need this behavior:

await ULink.instance.initialize(
ULinkConfig(
apiKey: 'your-api-key',
persistLastLinkData: true, // Enable persistence
),
);

3. Complete Example

Here's a complete example showing how to set up link reception in a Flutter app:

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_ulink_sdk/flutter_ulink_sdk.dart';
import 'package:flutter_ulink_sdk/models/models.dart';

void main() async {
WidgetsFlutterBinding.ensureInitialized();

// Initialize ULink SDK
await ULink.instance.initialize(
ULinkConfig(
apiKey: 'your-api-key',
baseUrl: 'https://api.ulink.ly',
debug: true,
persistLastLinkData: true,
),
);

runApp(const MyApp());
}

class MyApp extends StatefulWidget {
const MyApp({super.key});


State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
final _sdk = ULink.instance;
StreamSubscription<ULinkResolvedData>? _dynamicSub;
StreamSubscription<ULinkResolvedData>? _unifiedSub;


void initState() {
super.initState();
_setupLinkListeners();
_checkInitialLink();
}


void dispose() {
_dynamicSub?.cancel();
_unifiedSub?.cancel();
super.dispose();
}

void _setupLinkListeners() {
debugPrint('Setting up link listeners');

// Listen for dynamic links
_dynamicSub = _sdk.onDynamicLink.listen((linkData) {
debugPrint('Dynamic link received: ${linkData.toJson()}');
_handleLink(linkData);
}, onError: (error) {
debugPrint('Error in dynamic link stream: $error');
});

// Listen for unified links
_unifiedSub = _sdk.onUnifiedLink.listen((linkData) {
debugPrint('Unified link received: ${linkData.toJson()}');
_handleUnifiedLink(linkData);
}, onError: (error) {
debugPrint('Error in unified link stream: $error');
});

debugPrint('Link listeners setup complete');
}

Future<void> _checkInitialLink() async {
try {
final initialLink = await _sdk.getInitialDeepLink();
if (initialLink != null) {
debugPrint('App launched from link: ${initialLink.slug}');
_handleLink(initialLink);
}
} catch (e) {
debugPrint('Error getting initial link: $e');
}
}

void _handleLink(ULinkResolvedData data) {
// Navigate based on link data
final parameters = data.parameters;
if (parameters != null) {
final screen = parameters['screen'] as String?;
switch (screen) {
case 'product':
final productId = parameters['productId'] as String?;
if (productId != null) {
_navigateToProduct(productId);
}
break;
case 'profile':
final userId = parameters['userId'] as String?;
if (userId != null) {
_navigateToProfile(userId);
}
break;
default:
debugPrint('Unknown screen: $screen');
}
}
}

void _handleUnifiedLink(ULinkResolvedData data) {
// Handle marketing/redirect links
debugPrint('Unified link received: ${data.slug}');
// Optionally open externally using url_launcher
// final url = data.iosUrl ?? data.androidUrl ?? data.fallbackUrl;
// if (url != null) {
// launchUrl(Uri.parse(url));
// }
}

void _navigateToProduct(String productId) {
// Your navigation logic
// For example, using Navigator or a routing package like go_router
Navigator.of(context).pushNamed('/product', arguments: productId);
}

void _navigateToProfile(String userId) {
// Your navigation logic
Navigator.of(context).pushNamed('/profile', arguments: userId);
}


Widget build(BuildContext context) {
return MaterialApp(
title: 'My App',
home: HomeScreen(),
routes: {
'/product': (context) => ProductScreen(),
'/profile': (context) => ProfileScreen(),
},
);
}
}

4. Deferred Deep Linking

Deferred deep linking allows you to deep link users to specific content even if they haven't installed your app yet. When a user clicks your link, gets redirected to the app store, installs your app, and opens it for the first time — the original deep link data is preserved and delivered to your app.

How It Works

  1. User clicks link → App not installed
  2. ULink stores → Click data + device fingerprint
  3. User redirected → To App Store / Play Store
  4. User installs → Downloads and installs app
  5. User opens app → First launch triggers initialize() or checkDeferredLink()
  6. SDK matches → Finds original click data
  7. App receives → Original link data via onDynamicLink stream
  8. User navigates → To the intended content

By default, the SDK automatically checks for deferred links on first app launch:

await ULink.instance.initialize(
ULinkConfig(
apiKey: 'your-api-key',
autoCheckDeferredLink: true, // Default: true
),
);

// Deferred links are delivered via the same streams as regular links
_sdk.onDynamicLink.listen((linkData) {
if (linkData.isDeferred) {
debugPrint('This is a deferred deep link!');
debugPrint('Match type: ${linkData.matchType}');
}
_handleLink(linkData);
});
PropertyTypeDescription
isDeferredbooltrue if this link came from deferred deep linking
matchTypeString?How the link was matched: "deterministic" (Install Referrer) or "probabilistic" (fingerprint)
iOS Network Permission Dialog

On iOS, when your app makes its first network request, the system shows a permission dialog asking the user to allow network access. The problem is that the network request is already in flight when this dialog appears — the request will fail regardless of whether the user accepts or denies.

This means if autoCheckDeferredLink: true and the SDK initializes immediately, the deferred link check will fail before the user can even respond to the dialog.

For iOS apps, delay SDK initialization entirely until after your onboarding flow or until after the user has made their first successful network request:

import 'dart:io';

void main() async {
WidgetsFlutterBinding.ensureInitialized();

// On Android, initialize immediately - no permission issues
if (Platform.isAndroid) {
await _initializeULink();
}

runApp(const MyApp());
}

Future<void> _initializeULink() async {
await ULink.instance.initialize(
ULinkConfig(
apiKey: 'your-api-key',
autoCheckDeferredLink: true, // Works fine on Android
),
);
}

// In your onboarding screen or after first successful network call:
class OnboardingScreen extends StatelessWidget {
void onOnboardingComplete() async {
// On iOS, initialize SDK only after onboarding
if (Platform.isIOS) {
await _initializeULink();
}

// Navigate to home screen
Navigator.of(context).pushReplacementNamed('/home');
}
}
Best Practice for iOS
  1. Don't initialize ULink immediately in main()
  2. Complete your app's onboarding or make another network request first (e.g., login, fetch config)
  3. Then call ULink.instance.initialize() — by this point, iOS has already granted network permission
  4. The deferred link check will now succeed and deliver via your onDynamicLink listener

Matching Methods

The SDK uses two methods to match deferred links:

MethodPlatformAccuracyDescription
Install ReferrerAndroid only100% deterministicUses Google Play's Install Referrer API to get the exact click ID
Fingerprint MatchingiOS & Android~80-95% probabilisticMatches based on device fingerprint, IP, and timing

On Android, the SDK automatically uses Install Referrer when available, falling back to fingerprint matching if needed.

Important Notes

  1. One-Time Check: Deferred link check only runs once per app installation. Subsequent calls to checkDeferredLink() are ignored.

  2. 24-Hour Expiry: Deferred link data expires 24 hours after the original click.

  3. Network Required: The check requires an active network connection.

  4. Fresh Install Only: Deferred links only work on fresh installs, not app updates.

5. Integration with Routing Packages

Using go_router

import 'package:go_router/go_router.dart';

void _handleLink(ULinkResolvedData data) {
final parameters = data.parameters;
if (parameters != null) {
final screen = parameters['screen'] as String?;
switch (screen) {
case 'product':
final productId = parameters['productId'] as String?;
if (productId != null) {
context.go('/product/$productId');
}
break;
case 'profile':
final userId = parameters['userId'] as String?;
if (userId != null) {
context.go('/profile/$userId');
}
break;
}
}
}

Using auto_route

import 'package:auto_route/auto_route.dart';

void _handleLink(ULinkResolvedData data) {
final parameters = data.parameters;
if (parameters != null) {
final screen = parameters['screen'] as String?;
switch (screen) {
case 'product':
final productId = parameters['productId'] as String?;
if (productId != null) {
context.router.push(ProductRoute(productId: productId));
}
break;
}
}
}

Best Practices

  • Always check for initial links: Use getInitialDeepLink() when your app starts to handle links that launched the app
  • Cancel subscriptions: Always cancel stream subscriptions in dispose() to prevent memory leaks
  • Handle both stream types: Set up listeners for both onDynamicLink and onUnifiedLink
  • Validate parameters: Always validate and safely access parameters before using them
  • Handle errors: Add onError callbacks to stream listeners to handle errors gracefully
  • Centralize navigation logic: Create a single method to handle all link-based navigation
  • Handle edge cases: Account for missing parameters or invalid link data
  • Provide fallbacks: If navigation fails, show an appropriate error or fallback screen
  • Use routing packages: Consider using packages like go_router or auto_route for better navigation management

State Management

  • Update UI reactively: Use setState or state management solutions to update UI when links are received
  • Store link data: Consider storing link data in your state management solution if needed across the app
  • Handle app lifecycle: Check for initial and last links when the app starts

Troubleshooting

  • Verify URL scheme is configured in iOS (Info.plist)
  • Check Associated Domains are properly set up in iOS
  • Verify intent filters are configured in AndroidManifest.xml
  • Ensure domain matches dashboard configuration
  • Test on both platforms separately

Streams Not Emitting

  • Confirm SDK initialization completed successfully
  • Check that streams are set up after SDK initialization
  • Verify native platforms are properly configured
  • Enable debug mode to see processing logs
  • Check that DEBUG: Link listeners setup complete appears in logs

Parameters Missing

  • Verify link was created with parameters
  • Check that you're listening to the correct stream (dynamic vs unified)
  • Ensure parameters are accessed safely with null checks
  • Check the resolved data structure in debug logs

Testing Tips

iOS Testing

  • Use the Notes app or Safari to open your ULink
  • Test both custom schemes and universal links
  • Check Console.app for link processing logs

Android Testing

adb shell am start \
-a android.intent.action.VIEW \
-d "https://links.shared.ly/slug"
  • Verify Logcat contains link processing messages
  • Test both custom schemes and App Links

General Testing

  • Confirm DEBUG: Link listeners setup complete appears before tapping the link
  • Test cold starts (app launched from link)
  • Test warm starts (app already running)
  • Verify streams emit data when links are tapped

Next Steps