Flutter SDK
Deep linking, deferred attribution, user identity, custom events, and campaign link generation for Android and iOS.
Jump to
Requirements
- Flutter / Dart: as declared in the package (SDK
>=2.17.0 <4.0.0). - Platforms: Android and iOS (plugin class
FlutterDeeplinklyPluginon both). - API key: set natively as
DEEPLINKLY_API_KEY(not hard-coded in Dart for release builds; use build flavors or obfuscation where applicable). You can create a key in App Settings.
Install
Add the dependency and fetch packages.
In pubspec.yaml under dependencies, or from the project root with:
dependencies:
flutter_deeplinkly: ^1.7.0flutter pub add flutter_deeplinklyflutter pub add updates the lockfile and fetches dependencies in one step—you do not need a separate flutter pub get afterward.
If you added the dependency by editing pubspec.yaml by hand, run flutter pub get from the project root before continuing.
In Dart, import the package and models:
import 'package:flutter_deeplinkly/flutter_deeplinkly.dart';
import 'package:flutter_deeplinkly/models/deeplinkly.dart'; // link modelsInitialization and lifecycle
You must call init() once before listening to the stream. The native side uses flutterReady to align cold-start intents with the first Flutter frame.
import 'package:flutter/material.dart';
import 'package:flutter_deeplinkly/flutter_deeplinkly.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
FlutterDeeplinkly.init();
runApp(const MyApp());
}Order matters
Call WidgetsFlutterBinding.ensureInitialized() first, then FlutterDeeplinkly.init(), then runApp. Subscribing in initState of a widget below runApp is correct; do not read deepLinkStream before init().
The instance registers a WidgetsBindingObserver and forwards lifecycle changes to the native layer (onLifecycleChange), and retries marking Flutter ready when the app resumes.
Deep links
Subscribe once at app root, normalize the map to your route model, and navigate in a way that works for both cold and warm start.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_deeplinkly/flutter_deeplinkly.dart';
class DeeplinklyBootstrap extends StatefulWidget {
const DeeplinklyBootstrap({super.key, required this.child});
final Widget child;
@override
State<DeeplinklyBootstrap> createState() => _DeeplinklyBootstrapState();
}
class _DeeplinklyBootstrapState extends State<DeeplinklyBootstrap> {
StreamSubscription<Map<dynamic, dynamic>>? _sub;
@override
void initState() {
super.initState();
_sub = FlutterDeeplinkly.instance.deepLinkStream.listen(_onDeepLink);
}
void _onDeepLink(Map<dynamic, dynamic> payload) {
// Payload shape depends on native resolution — normalize before routing.
debugPrint('Deeplinkly deep link: $payload');
}
@override
void dispose() {
_sub?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) => widget.child;
}Payload contract
The onDeepLink / deepLinkStream value is a Map the native code builds from the resolve-click response (and from the open URL on fallback). Shape is the same on Android and iOS, except where noted below.
After a successful resolve
click_id—String(from the link and/or the JSON body).params—Mapcontaining the campaign/link params object as returned by the API. Access individual values via(map['params'] as Map)['your_key']. Present on both Android and iOS.probability— included when the API returns a non-negative probability (fingerprinting).
If resolve fails (fallback delivery)
The map is smaller: click_id and whatever could be read from the URL. On Android, that is the full set of query parameters from the deep link, plus android_reported_at when available. On iOS, the plugin forwards utm_source, utm_medium, utm_campaign, utm_term, utm_content, gclid, fbclid, and ttclid from the link when present.
At the type level the stream is still Map (for example Map<dynamic, dynamic>): normalize keys to your route model, validate required fields, and use a single router so you do not double-navigate on resume.
The deprecated FlutterDeeplinkly.onResolved API wraps the same stream; prefer deepLinkStream.listen for explicit subscription control and error boundaries.
Android configuration
Use HTTPS App Links for production domains and a custom scheme for dev or fallback. Place the API key in application metadata the native SDK reads at runtime.
Edit android/app/src/main/AndroidManifest.xml. The example below shows both an https host (with pathPrefix) and a custom scheme — adjust to match your Dashboard link domain and your marketing URLs.
<!-- android/app/src/main/AndroidManifest.xml -->
<activity
android:name=".MainActivity"
android:exported="true"
...>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="links.yourdomain.com"
android:pathPrefix="/l" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="yourapp" android:host="open" />
</intent-filter>
</activity>
<application ...>
<meta-data
android:name="DEEPLINKLY_API_KEY"
android:value="@string/deeplinkly_api_key" />
</application>- App Links: for
android:autoVerify="true", host the Digital Asset Links file athttps://your.host/.well-known/assetlinks.jsonwith your signing key fingerprints. - Package name must match
applicationIdinbuild.gradlefor any adb tests.
iOS configuration
URL schemes are the fastest path to testing; Universal Links need Associated Domains and a hosted apple-app-site-association file on your link domain.
<!-- ios/Runner/Info.plist (keys as examples) -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>com.your.app.deeplinkly</string>
<key>CFBundleURLSchemes</key>
<array>
<string>yourapp</string>
</array>
</dict>
</array>
<key>DEEPLINKLY_API_KEY</key>
<string>$(DEEPLINKLY_API_KEY)</string>Add applinks:links.yourdomain.com (example) under Signing & Capabilities → Associated Domains in Xcode when you enable Universal Links. The exact host must match the domain you use in the Deeplinkly dashboard and in your AASA file.
Identity and install attribution
Use Deeplinkly’s stable install id and optional custom user id together with getInstallAttribution for campaign context.
final attribution = await FlutterDeeplinkly.getInstallAttribution();
// Map<String, String> — may be empty on first frame; retry after splash if needed.
final deeplinklyId = await FlutterDeeplinkly.getDeeplinklyId();
// Stable per-install id; aligns with deeplinkly_device_id / X-Deeplinkly-User-Id server-side.
await FlutterDeeplinkly.setUserId(authenticatedUserId);
// Call after login and clear on logout with setUserId(null).Custom events
logEvent enforces size and type rules on the client before invoking native code.
final ok = await FlutterDeeplinkly.logEvent(
'purchase',
parameters: {
'order_id': 'ord_42',
'amount': 49.99,
'currency': 'USD',
},
);
if (!ok) {
// Failed validation or native/rejected — check param limits below.
}Validation (client-side)
- Event name: non-empty, max 64 characters (after trim).
- At most 25 parameters.
- Parameter keys: non-empty, max 64 characters.
- String values: max 256 characters.
- Value types:
String,num,bool,List,Map— other types are rejected.
The DeeplinklyEventType enum provides standard names (for example DeeplinklyEventType.purchase.eventName → purchase); you can still use raw strings for custom funnels.
Link generation
Build structured Deeplinkly links from the app for referrals, UTM-tagged shares, and programmatic growth loops.
import 'package:flutter_deeplinkly/models/deeplinkly.dart';
final result = await FlutterDeeplinkly.generateLink(
content: const DeeplinklyContent(
canonicalIdentifier: 'product/sku_42',
title: 'Pro Plan',
description: 'Upgrade to Pro',
imageUrl: 'https://cdn.example.com/og/pro.png',
metadata: <String, dynamic>{'plan': 'pro', 'price_inr': 999},
),
options: const DeeplinklyLinkOptions(
channel: 'email',
feature: 'spring_sale',
tags: <String, dynamic>{'cohort': 'power_users'},
),
);
if (result.success) {
final url = result.url;
} else {
final code = result.errorCode;
final message = result.errorMessage;
}Debug mode
// Dev / staging only
await FlutterDeeplinkly.setDebugMode(true);API reference (Dart)
| Method | Returns | Description |
|---|---|---|
| FlutterDeeplinkly.init() | void | Registers the method channel, lifecycle observer, and flushes any queued deep links once Flutter is ready. |
| instance.deepLinkStream | Stream<Map> | Broadcast stream of resolved deep link payloads. Prefer one subscription from a top-level widget or app service. |
| getInstallAttribution() | Future<Map<String, String>> | Install and campaign attribution. May be empty on the first frame after cold start. |
| getDeeplinklyId() | Future<String> | Stable per-install id (same concept as server-side `deeplinkly_device_id`). |
| setUserId(String? userId) | Future<void> | Sets your app’s user id for Deeplinkly enrichment. Clear with null on logout. |
| logEvent(name, { parameters }) | Future<bool> | Validated custom event; returns true when the native layer accepts the call. |
| generateLink({ content, options }) | Future<DeeplinklyResult> | Builds a campaign link. Inspect `success`, `url`, and `errorCode` / `errorMessage`. |
| setDebugMode(bool enabled) | Future<void> | Enables verbose native logging for integration debugging. |
Recommended architecture
Structure your app so deep link handling is deterministic and testable.
- DeeplinklyBootstrap widget (or an app service) owns the single
StreamSubscription. - DeeplinklyRouter pure functions:
Map→ your sealed route class (e.g.ProductRoute(id)). - Navigation adapter one place that talks to
NavigatororGoRouter/Routerconfig. - Idempotency: ignore duplicate payloads with the same logical key (deeplink id + path + query hash).
Testing
Trigger a custom scheme on a device or emulator with adb:
# Replace package with your applicationId
adb shell am start -a android.intent.action.VIEW \
-d "yourapp://open/product/123" com.your.appOn iOS, use Safari or the Shortcuts app to open your URL scheme, or xcrun simctl with the appropriate open invocation. Verify cold start (process killed) and warm start (app backgrounded) separately.
Troubleshooting
No event on stream
Confirm init() ran before the first listener. Re-check MainActivity / SceneDelegate opens correctly for the link type (scheme vs universal link).
Attribution always empty
Read after splash; on first open the native store may not be ready. Avoid blocking UI. Confirm install is genuinely fresh for deferred tests.
logEvent returns false
Check name length, param count, key length, and string value length. Reject complex objects other than list/map/num/bool/string.
generateLink errors
Read errorCode: NULL_NATIVE_RESPONSE, PLATFORM_EXCEPTION, or native-provided code. Ensure API key and network allow the native call.
Autonomous AI skill
Use this runbook in Cursor (or any agent) for goal-driven implementation with guardrails. Full text is available below; copy the block for your .cursor or internal tooling.
For AI agents
The prompt defines intent detection, a full execution checklist, validation gates, and troubleshooting so an agent can implement Deeplinkly with minimal back-and-forth.
# Deeplinkly Flutter SDK — Autonomous Integration Agent
You are an autonomous agent whose job is to fully integrate the Deeplinkly Flutter SDK into this project with minimal back-and-forth. Follow these phases in order. Never skip Phase 1. Never ask questions one at a time — batch them.
---
## PHASE 1 — Codebase scan (do this silently before asking anything)
Read the following files if they exist:
- \`pubspec.yaml\` — detect flutter_deeplinkly version already installed, navigation packages (go_router, auto_route, beamer), state management (riverpod, bloc, provider), existing auth packages
- \`lib/main.dart\` — detect if FlutterDeeplinkly.init() already exists, current runApp structure
- \`android/app/src/main/AndroidManifest.xml\` — detect existing intent filters, applicationId, existing DEEPLINKLY_API_KEY metadata
- \`ios/Runner/Info.plist\` — detect existing URL schemes, existing DEEPLINKLY_API_KEY key
- Any file named *router*, *routes*, *navigation* — understand current routing architecture
- Any file named *auth*, *user*, *session* — understand identity management
From the scan, infer as many answers as possible before asking the user anything.
---
## PHASE 2 — Discovery (ask all questions in a single message)
Greet the user briefly, then ask all of the following that you could NOT infer from the scan. Format them as a numbered list so the user can reply with numbers:
Base (always ask if not inferable):
1. What is your Deeplinkly API key? (You can find it in App Settings on the dashboard. It will be stored natively, not hard-coded in Dart.)
2. What navigation library are you using? (Navigator 1.0 / GoRouter / AutoRoute / Beamer / other)
3. Do you target Android, iOS, or both?
Platform setup (ask if manifest/plist entries are missing):
4. What is your custom URL scheme? (e.g. "myapp" → myapp://open/…)
5. Do you want HTTPS App Links (Android) or Universal Links (iOS) in addition to the custom scheme? If yes, what is your link domain? (e.g. links.yourdomain.com)
Feature selection — answer yes/no for each, or say "all":
6. Deep link routing — opens the correct screen when a Deeplinkly link is tapped. [Recommended: yes]
7. Deferred attribution — reads install campaign data (UTM params, referral source) after a fresh install. [Recommended: yes]
8. User identity linking — ties your logged-in user ID to Deeplinkly for cross-device attribution.
9. Event tracking — logs custom events (purchase, sign_up, etc.) from Dart.
10. Share / referral link generation — lets users share Deeplinkly campaign links from inside the app.
Optional:
11. Do you use build flavors or environments (dev/staging/prod) with different API keys? (yes / no)
12. Should I enable debug mode in non-production builds for verbose native logging? (yes / no)
Tell the user: "Answer with the numbers you want to enable for features 6–10, or just say 'all'. For 1–5 and 11–12, give me the values directly."
---
## PHASE 3 — Implementation plan (show before writing any code)
Based on the answers, output a short plan listing exactly which modules you will implement, in order. Ask the user to confirm before proceeding. Example:
"Here is what I will implement:
✓ Base install + init
✓ Android manifest (custom scheme + HTTPS App Links for links.example.com)
✓ iOS Info.plist (custom scheme)
✓ Deep link routing → GoRouter
✓ Deferred attribution (read on splash)
✓ Event tracking wrapper
✗ Share link generation (skipped — you said no)
✗ User identity (skipped — you said no)
Shall I proceed?"
---
## PHASE 4 — Module execution
Execute only the modules confirmed in Phase 3. Follow each module spec exactly.
### MODULE A — Install + init (always)
1. If flutter_deeplinkly is not in pubspec.yaml, add it under dependencies.
2. In lib/main.dart, ensure this exact order — WidgetsFlutterBinding first, then init(), then runApp():
void main() {
WidgetsFlutterBinding.ensureInitialized();
FlutterDeeplinkly.init();
runApp(const MyApp());
}
3. Enable debug mode if requested — add FlutterDeeplinkly.setDebugMode(true) guarded by kDebugMode or a flavor check, NOT inside main() (put it in a post-frame callback or your app bootstrap widget).
Validation: Run flutter pub get. Confirm no analysis errors on main.dart.
### MODULE B — Android platform setup (if targeting Android)
Edit android/app/src/main/AndroidManifest.xml:
- Add custom scheme intent filter to MainActivity.
- If HTTPS App Links requested, add a second intent filter with android:autoVerify="true" for the provided domain with android:pathPrefix="/l".
- Add DEEPLINKLY_API_KEY as <meta-data> inside <application>, referencing @string/deeplinkly_api_key.
- Create or update android/app/src/main/res/values/strings.xml with the actual API key value.
Remind the user: For App Links to work, they must host /.well-known/assetlinks.json on their domain with their signing fingerprint.
Validation: Confirm applicationId in build.gradle matches the package attribute in the manifest.
### MODULE C — iOS platform setup (if targeting iOS)
Edit ios/Runner/Info.plist:
- Add CFBundleURLTypes entry with the custom scheme.
- Add DEEPLINKLY_API_KEY key with the string value.
If Universal Links requested:
- Remind the user to add applinks:<domain> under Signing & Capabilities → Associated Domains in Xcode.
- Remind the user to host an apple-app-site-association file on their domain.
Validation: Confirm the scheme string matches what will be registered in the Deeplinkly dashboard.
### MODULE D — Deep link routing
#### Payload contract — read this before writing any routing code
The deepLinkStream emits a Map<dynamic, dynamic>. Shape after a successful resolve:
- click_id — String at the top level
- params — Map containing campaign/link params, e.g. { 'product_id': '42', 'promo': 'summer' }
- probability — double, present only when fingerprinting is used
ALWAYS access params like this:
final params = (payload['params'] as Map?) ?? {};
final productId = params['product_id'] as String?;
NEVER do payload['product_id'] directly — params are nested under the 'params' key, not flattened to the top level.
If resolve fails (fallback), the map contains click_id and raw query params from the URL. Android also includes android_reported_at. iOS includes utm_source, utm_medium, utm_campaign, utm_term, utm_content, gclid, fbclid, ttclid if they were in the link.
#### Implementation steps
1. Create lib/deeplinkly/deeplinkly_router.dart:
class DeeplinklyRoute {
final String target;
final Map<String, String> params;
final String? source;
DeeplinklyRoute({required this.target, this.params = const {}, this.source});
}
class DeeplinklyRouter {
static DeeplinklyRoute? resolve(Map payload) {
final params = (payload['params'] as Map?) ?? {};
// Map param keys to routes — examples:
// if (params['screen'] == 'product') return DeeplinklyRoute(target: '/product', params: {'id': params['product_id'] ?? ''});
// if (params['screen'] == 'promo') return DeeplinklyRoute(target: '/promo', params: {'code': params['code'] ?? ''});
return null; // fallback: stay on current screen
}
}
2. Create lib/deeplinkly/deeplinkly_bootstrap.dart — a StatefulWidget wrapping the app root that owns a single StreamSubscription:
class DeeplinklyBootstrap extends StatefulWidget {
const DeeplinklyBootstrap({super.key, required this.child});
final Widget child;
@override
State<DeeplinklyBootstrap> createState() => _DeeplinklyBootstrapState();
}
class _DeeplinklyBootstrapState extends State<DeeplinklyBootstrap> {
StreamSubscription<Map<dynamic, dynamic>>? _sub;
String? _lastClickId;
@override
void initState() {
super.initState();
_sub = FlutterDeeplinkly.instance.deepLinkStream.listen(_onDeepLink);
}
void _onDeepLink(Map<dynamic, dynamic> payload) {
final clickId = payload['click_id'] as String?;
if (clickId != null && clickId == _lastClickId) return; // idempotency guard
_lastClickId = clickId;
final route = DeeplinklyRouter.resolve(payload);
if (route == null) return;
// Navigate using your router — examples:
// Navigator 1.0: Navigator.of(context).pushNamed(route.target, arguments: route.params);
// GoRouter: context.go(route.target, extra: route.params);
// AutoRoute: context.router.pushNamed(route.target);
}
@override
void dispose() {
_sub?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) => widget.child;
}
3. Wrap the root widget with DeeplinklyBootstrap inside MaterialApp or MaterialApp.router.
4. Ask the user: "What screens do you want deep links to open? Give me a list of screens and the param key that identifies them (e.g. screen=product uses product_id)." Then fill in the route mapping in deeplinkly_router.dart.
Validation: Test cold start and warm start using adb (Android) or xcrun simctl (iOS). Confirm correct screen opens each time.
### MODULE E — Deferred attribution
1. Create lib/deeplinkly/deeplinkly_attribution.dart:
class DeeplinklyAttribution {
static Map<String, String>? _cache;
static Future<Map<String, String>> fetch() async {
if (_cache != null) return _cache!;
final data = await FlutterDeeplinkly.getInstallAttribution();
if (data.isNotEmpty) _cache = data;
return data;
}
}
2. Call DeeplinklyAttribution.fetch() in your splash or onboarding screen, after the first frame via WidgetsBinding.instance.addPostFrameCallback. Do NOT await it on the critical render path.
3. Persist at minimum utm_source, utm_medium, and utm_campaign to your analytics layer.
Validation: On a fresh install (or after clearing app data), confirm the attribution map is non-empty within a few seconds of app open.
### MODULE F — User identity
1. Ask the user: "Where is your login success callback or onAuthStateChanged handler?" Navigate to that location.
2. After successful login, add:
await FlutterDeeplinkly.setUserId(user.id);
3. On logout, add:
await FlutterDeeplinkly.setUserId(null);
Validation: Confirm the call is not inside a widget's build() method and is not fired multiple times per session.
### MODULE G — Event tracking
1. Create lib/deeplinkly/deeplinkly_events.dart:
// SDK validation constraints:
// - event name: non-empty, max 64 chars
// - max 25 params per event
// - param keys: max 64 chars; string values: max 256 chars
// - value types: String, num, bool, List, Map only
class DL {
static Future<void> track(String event, [Map<String, Object>? params]) async {
final ok = await FlutterDeeplinkly.logEvent(event, parameters: params ?? {});
assert(ok, 'Deeplinkly rejected event "$event" — check name/param constraints');
}
static Future<void> purchase({required String orderId, required double amount, String currency = 'USD'}) =>
track('purchase', {'order_id': orderId, 'amount': amount, 'currency': currency});
static Future<void> signUp({required String method}) =>
track('sign_up', {'method': method});
static Future<void> screenView(String screen) =>
track('screen_view', {'screen': screen});
}
2. Ask the user: "Which events do you want to track? Give me a list with the event name and the data each one carries. I will add a typed method to DL for each."
3. Add the typed methods, then replace any loose analytics calls the user already has with DL.track() calls where appropriate.
Validation: With debug mode on, confirm DL.purchase() returns true in the console.
### MODULE H — Share / referral link generation
1. Create lib/deeplinkly/deeplinkly_share.dart:
class DeeplinklyShare {
static Future<String?> buildLink({
required String canonicalId,
required String title,
String? description,
String? imageUrl,
Map<String, dynamic> metadata = const {},
String channel = 'share',
String? campaign,
}) async {
final result = await FlutterDeeplinkly.generateLink(
content: DeeplinklyContent(
canonicalIdentifier: canonicalId,
title: title,
description: description,
imageUrl: imageUrl,
metadata: metadata,
),
options: DeeplinklyLinkOptions(
channel: channel,
feature: campaign ?? 'referral',
),
);
if (result.success) return result.url;
debugPrint('Deeplinkly generateLink failed: ${result.errorCode} ${result.errorMessage}');
return null;
}
}
2. Ask the user: "What do you want users to share — product pages, referral invites, promo codes, or something else? I will add a specific helper for each."
3. Wire the share button to call DeeplinklyShare.buildLink(), then pass the URL to the platform share sheet (e.g. using the share_plus package).
Validation: Confirm the returned URL opens the correct screen on a second device or simulator.
---
## PHASE 5 — Final validation checklist
After all modules are written, output this checklist and walk through each item with the user. Mark each item done only after the user confirms it passes.
[ ] flutter pub get — no errors
[ ] flutter analyze — no warnings in lib/deeplinkly/ files
[ ] Android: adb cold-start fires onDeepLink and routes correctly
[ ] Android: adb warm-start fires onDeepLink and routes correctly
[ ] iOS: xcrun simctl cold-start fires onDeepLink (if targeting iOS)
[ ] Attribution map non-empty after fresh install (Module E — if enabled)
[ ] setUserId called after login and cleared on logout (Module F — if enabled)
[ ] DL.track() returns true for at least one event (Module G — if enabled)
[ ] Share URL opens correct screen on another device (Module H — if enabled)
[ ] Debug logging disabled / not present in release builds
---
## GUARDRAILS (enforce at all times)
- Never hard-code API keys in Dart files. Always store natively (AndroidManifest meta-data / Info.plist).
- Never block main() or build() on network calls.
- Never subscribe to deepLinkStream more than once across the app lifetime.
- Always guard idempotency: check click_id before routing, skip duplicates.
- Always access campaign params as (payload['params'] as Map?)?['key'] — never flat keys on the top-level payload.
- If a step fails, tell the user exactly what went wrong and offer one concrete fix before asking what to do next.
- If the user is not sure about a feature, explain what it does in one sentence and give a recommendation before proceeding.
Repository & more docs
Extended README, markdown guide, and the Cursor skill file live in the open-source package: README.md, docs/FLUTTER_SDK.md, .cursor/skills/flutter-sdk/SKILL.md.