Flutter SDK
Deep linking, deferred attribution, user identity, custom events, and campaign link generation for Android and iOS.
Jump to
- Flutter / Dart: SDK
>=2.17.0 <4.0.0, Flutter>=2.10.0. - Platforms: Android and iOS.
- API key: set natively, per platform — never hard-coded in Dart. You can create a key in App Settings.
Native key names
| Purpose | Android | iOS |
|---|---|---|
| API key | com.deeplinkly.sdk.api_key | DeeplinklyApiKey |
| Link domain allowlist | com.deeplinkly.sdk.link_domains | DeeplinklyLinkDomains |
| Attribution level | com.deeplinkly.sdk.attribution_level | DeeplinklyAttributionLevel |
| Deferred mechanism | Play Install Referrer | Pasteboard / DeeplinklyPasteButton |
| Pasteboard opt-out | n/a — Android has no pasteboard path | DeeplinklyCheckPasteboardOnInstall |
dependencies:
flutter_deeplinkly: ^1.11.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 modelsYou must call init() once before listening to the stream. The native side aligns 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, and retries marking Flutter ready when the app resumes.
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;
State<DeeplinklyBootstrap> createState() => _DeeplinklyBootstrapState();
}
class _DeeplinklyBootstrapState extends State<DeeplinklyBootstrap> {
StreamSubscription<Map<dynamic, dynamic>>? _sub;
void initState() {
super.initState();
_sub = FlutterDeeplinkly.instance.deepLinkStream.listen(_onDeepLink);
}
void _onDeepLink(Map<dynamic, dynamic> payload) {
final clickId = payload['click_id'] as String?;
final params = payload['params'] as Map? ?? const {};
// Normalize to your route model before navigating, and dedupe so a
// resume does not navigate twice.
debugPrint('Deeplinkly $clickId -> $params');
}
void dispose() {
_sub?.cancel();
super.dispose();
}
Widget build(BuildContext context) => widget.child;
}Payload envelope — identical on Android and iOS
{
'click_id': 'ab12…', // null if the backend did not recognise the click
'params': {'screen': 'home'}, // the link's own parameters
}One read path, resolved or not
params carries the link's parameters whether they came back from the backend or — when it could not be reached — from the URL itself, so a single read path covers both cases. click_id is null if the backend did not recognise the click.
At the type level the stream is 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.
Use HTTPS App Links for production domains and a custom scheme for dev or fallback.
<!-- android/app/src/main/AndroidManifest.xml -->
<activity android:name=".MainActivity" android:exported="true">
<!-- App Links. The one that matters: it lets a tap on
https://links.yourapp.com/abc123 open the app directly. Without it every
link detours through the browser, and in-app browsers that block
intent:// URLs (Instagram, Facebook, TikTok) never reach your app at all
— even when it is installed. autoVerify only works on http/https. -->
<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.yourapp.com" />
</intent-filter>
<!-- Custom scheme: the browser fallback path. Keep it, but it is a
fallback, not a substitute for the filter above. -->
<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="deeplink" />
</intent-filter>
</activity>
<application ...>
<meta-data
android:name="com.deeplinkly.sdk.api_key"
android:value="your_api_key_here" />
<!-- Optional. Set it if the app App Links any host besides its Deeplinkly
link domain. Comma separated. -->
<meta-data
android:name="com.deeplinkly.sdk.link_domains"
android:value="links.yourapp.com" />
</application>Replace links.yourapp.com with your Deeplinkly link domain, and yourapp with the URI scheme you set in the dashboard.
Which links the SDK claims
The rule is the same on both platforms. A link that came through the redirect carries a click_id, and the SDK acts on that whatever the scheme. The ambiguous case is the App Link / Universal Link bypass, where the OS routes https://links.yourapp.com/<code> straight to the app and the first path segment is the only thing to resolve on. So:
- Custom-scheme URLs without a
click_idare ignored. Your own routes (yourapp://settings/notifications) are yours; the SDK will not resolve them. - http(s) URLs are resolved by code. If the link-domains list is set, only those hosts are; without it every https link the app handles is.
Set link domains if you App Link anything else
A marketing site, say — otherwise https://www.yourapp.com/pricing is resolved as the link code pricing.
Verifying App Links
Android checks https://<your-link-domain>/.well-known/assetlinks.json on install. Deeplinkly serves that file for you, but only once the dashboard has both your package name and your SHA-256 signing certificate fingerprint — with either missing the endpoint returns 404 and verification silently fails.
# Fingerprint from your release keystore. If you use Play App Signing, take it
# from Play Console → Test and release → Setup → App signing instead — Google
# re-signs the APK, so your upload fingerprint will not match what ships.
keytool -list -v -keystore <your-keystore> -alias <your-alias> | grep SHA256
# Confirm the file is live and verification passed.
curl https://links.yourapp.com/.well-known/assetlinks.json
adb shell pm get-app-links <your.package.name>pm get-app-links should report verified for your domain. none or legacy_failure means the fingerprint does not match or the file is not reachable.
Universal Links need Associated Domains and a hosted apple-app-site-association file on your link domain.
<!-- ios/Runner/Info.plist -->
<key>DeeplinklyApiKey</key>
<string>your_api_key_here</string>
<!-- Your Deeplinkly link domains. Subdomains count. Two jobs: the hosts a
deferred link may be read from, and the hosts whose first path segment may
be read as a link code. Set it if the app Universal Links any host besides
its link domain. -->
<key>DeeplinklyLinkDomains</key>
<array>
<string>yourbrand.deeplinkly.com</string>
</array>
<!-- Only if the project uses a custom scheme. -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>yourapp</string>
</array>
</dict>
</array>Then add an Associated Domains capability with applinks:yourbrand.deeplinkly.com for each link domain, and make sure CODE_SIGN_ENTITLEMENTS actually points at the entitlements file.
Do not forward links from AppDelegate
The plugin registers for both the UIApplicationDelegate and UIScene link callbacks itself. Hand-wiring handleUniversalLink on top of that delivers every link twice. If your project carries such code from an older integration, remove it.
iOS has no install-referrer API, so the link survives an App Store install via the pasteboard.
The Deeplinkly interstitial copies the link when the visitor taps through to the store, and the SDK reads it back on first launch. There are two ways to read it back — pick one.
Option A — DeeplinklyPasteButton (recommended, no banner)
A system paste button rendered in your widget tree. Because the user taps it themselves, iOS treats the tap as the grant and shows no “Pasted from…” banner at all.
DeeplinklyPasteButton(
onPasted: (handled) => setState(() => _showPasteButton = !handled),
fallback: const SizedBox.shrink(),
)Put it next to something like “Tapped a link to get here? Restore where you left off.” The recovered link arrives on deepLinkStream exactly like any other; onPasted only tells you whether the pasted content was one of your links. Requires iOS 16+, and renders fallback on Android and older iOS, so it is safe to place unconditionally.
Option B — automatic read (on by default, shows the banner)
On by default; you do not need to enable it. To turn it off:
<!-- Info.plist, not Dart: the read happens during plugin registration,
before any Dart runs, so setCheckPasteboardOnInstall(false) is too late
to prevent the first one. -->
<key>DeeplinklyCheckPasteboardOnInstall</key>
<false/>To explain the prompt before it appears, turn the automatic read off in Info.plist and drive it yourself:
// Explain the prompt before it appears: turn the automatic read off in
// Info.plist, then drive it yourself.
if (await FlutterDeeplinkly.willShowPasteboardBanner()) {
await showMyPrimingDialog(); // "we can restore where you left off"
await FlutterDeeplinkly.checkPasteboardNow();
}Either way
- The visitor must tap through the interstitial — there is no auto-redirect on iOS, because Safari will not allow a clipboard write without a user gesture.
- The automatic read happens once per install and probes the pasteboard's types first with
hasURLs, which is banner-free. The banner appears only when a URL is actually there. - The banner is not limited to your own links. Any URL on the clipboard triggers the read; the SDK then discards anything whose host is not in
DeeplinklyLinkDomains— but the banner has already shown. A user with no URL copied sees nothing. - The resolved click is stamped
attribution_source = "clipboard", notinstall_referrer— that API does not exist on iOS. - If the first launch is offline the pending resolve is persisted and retried next launch, so an offline install is not lost.
- Both paths are skipped entirely when tracking is disabled.
await FlutterDeeplinkly.setAttributionLevel(DeeplinklyAttributionLevel.reduced);| Level | What is sent |
|---|---|
| full | Everything. The default. |
| reduced | Drops every high-entropy hardware signal: screen geometry, model, CPU, local IP, WebView user agent, and the advertising ID / Android ID / IDFA / IDFV. Keeps the coarse context campaign reporting reads — locale, timezone, OS and app version. |
| minimal | Only the install id, app build, and the link being reported on. Nothing describing the device. |
| none | No enrichment at all. Links still resolve and still deliver. |
Each level is a strict subset of the one above. Deep link delivery works at every level, including none — this restricts reporting, not functionality. Resolving a link never sends anything describing the device, at any level.
To start restricted before any Dart runs (enrichment can be sent during plugin registration), set it natively:
<!-- ios/Runner/Info.plist -->
<key>DeeplinklyAttributionLevel</key>
<string>reduced</string><!-- AndroidManifest.xml, inside <application> -->
<meta-data android:name="com.deeplinkly.sdk.attribution_level"
android:value="reduced" />Stricter than the usual “reduced” tier
Some SDKs drop only the advertising identifiers at this tier and keep shipping screen geometry, local IP and the user agent. Here, any level below full drops all of it. setTrackingEnabled(false) still wins and behaves as none.
The SDK does not do probabilistic (“fingerprint”) matching. Device signals are collected for reporting, never to derive an identifier linking a click to an install — matching is deterministic, on the click id or the install referrer. Nothing describing the device is collected at click time, in the browser or the interstitial; all of it is in-app and post-install.
Hashing identifiers on the device
Off by default. With it on, the email, phone number and names given to setUserData are SHA-256 hashed on the device and plaintext never reaches Deeplinkly.
await FlutterDeeplinkly.setPIIHashingEnabled(true); // SHA-256 on device before sending
await FlutterDeeplinkly.isPIIHashingEnabled(); // off unless you turned it onNothing is hashed on the Dart side. The normalisation must match the service byte for byte or an erasure request stops finding the person it names, so there is one implementation per platform rather than a third here. Gender, country and date of birth are deliberately not hashed: their value ranges are small enough to reverse a digest by enumeration.
It costs attribution quality
A digest is computed once, under one normalisation, and advertising destinations disagree about phone formatting — so a conversion forwarded to a destination whose rules differ will not match, and the service can no longer re-derive per destination because the value it would need is gone. Turn it on when a compliance requirement says plaintext must not reach a processor, not by default.
Privacy manifest (iOS)
The SDK ships PrivacyInfo.xcprivacy in its resource bundle, so you do not declare its API usage yourself. By default NSPrivacyTracking is false and it reads no IDFA, so it needs no NSUserTrackingUsageDescription. The SDK never calls requestTrackingAuthorization in any configuration — it reads the status your own prompt produced.
Advertising ID on Android (opt-in)
The SDK compiles against play-services-ads-identifier but does not bundle it — that library declares the AD_ID permission, which would then be added to every app embedding the SDK, including apps under Play's Families policy. To report advertising_id, add it yourself:
// android/app/build.gradle
dependencies {
implementation 'com.google.android.gms:play-services-ads-identifier:18.2.0'
}Without it everything else works unchanged. The SDK declares only INTERNET.
IDFA on iOS (opt-in)
Set DeeplinklyEnableIDFA to true in Info.plist. The IDFA is then reported only while ATT status is already authorized — if you never prompt, nothing is collected, and that is the correct outcome rather than a bug. Enabling it makes your app a tracking app, so you must also merge the plugin's Resources/IDFA/PrivacyInfo.xcprivacy template, add NSUserTrackingUsageDescription, and call ATTrackingManager.requestTrackingAuthorization yourself.
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 server-side.
await FlutterDeeplinkly.setUserId(authenticatedUserId);
// Call after login and clear on logout with setUserId(null).The fields a conversion is matched on once it reaches Meta’s Conversions API or Google’s enhanced conversions.
await FlutterDeeplinkly.setUserData(
userId: 'user_123',
email: 'ada@example.com',
phoneNumber: '+441234567890',
firstName: 'Ada',
lastName: 'Lovelace',
city: 'London',
country: 'GB',
); // false if any field was malformed, in which case nothing was storedEvery field is optional and each call merges, so you can supply an email at sign-up and an address at checkout. A malformed field rejects the whole call — nothing is stored — so a rejected call never leaves you guessing which of the values took.
The rules are enforced natively rather than in Dart, so a native-only integration gets the same answer this one does. These fields survive a reduced downgrade, because attribution levels gate what the SDK observes about a device and an email someone typed into your app is not an observation. At none nothing is sent.
dateOfBirth:YYYY-MM-DD.gender:'m'or'f'— the only two values Meta'sgeaccepts. Anything else is refused rather than coerced.country: ISO-3166-1 alpha-2, for example'US'.- Every field has a maximum length, listed in the plugin’s SIGNALS.md.
Your own identifiers
customData carries identifiers Deeplinkly does not name — typically product-analytics ids such as a Mixpanel distinct id or a CleverTap id. Attach a new identifier anytime; no app release required.
await FlutterDeeplinkly.setUserData(
userId: 'user_123',
customData: {
'mixpanel_distinct_id': 'd-8837',
'clevertap_id': 'ct-4412',
},
);Up to 10 entries, keys up to 64 characters and values up to 256. The caps live in the native SDKs, so there is one implementation of the rule rather than three that can drift.
Erasing it
await FlutterDeeplinkly.clearUserData(); // erases everything setUserData and setUserId recorded
await FlutterDeeplinkly.setUserId(null); // clears only the idThis is not merely “stop sending”: the next enrichment carries each previously-set field as an empty value, which the service reads as null this column rather than not reported. The erasure is re-sent until it is delivered, so calling it on a device that is offline still takes effect once it is not.
Validation is enforced in the native layer, so the same rules apply to every host — not just Flutter callers.
final ok = await FlutterDeeplinkly.logEvent(
'purchase',
parameters: {
'order_id': 'ord_42',
'amount': 49.99, // stays a number end to end, not "49.99"
'currency': 'USD',
},
);
if (!ok) {
// Rejected by validation — see the limits below. No network call was made.
}Validation
- Event name: non-empty, max 64 characters.
- At most 25 custom parameters. The SDK's own
_dl_*keys do not count toward this, and passing a key with that prefix is rejected. - Parameter keys: max 64 characters.
- Parameter string values: max 256 characters.
List/Mapvalues are stored as compact JSON; the 256 limit applies to that encoded form.numandboolkeep their JSON types end to end —49.99is stored as a number, not"49.99".
A rejected event returns false and makes no network call. The DeeplinklyEventType enum provides standard names (for example DeeplinklyEventType.purchase.eventName → purchase); you can still use raw strings for custom funnels.
await FlutterDeeplinkly.logPurchase(
value: 49.99,
currency: 'USD',
orderId: 'ord_42',
quantity: 1,
productId: 'sku_9',
);Not a separate pipeline: it sends the event named purchase with value and currency set, so everything true of logEvent — the retry queue, the parameter limits, the device block — is true of this too.
value and currency are what Meta's Conversions API and Google's enhanced conversions both key off, so this one call feeds both without you having to match their spelling.
Rejected, sending nothing, if the value is negative or not finite (a refund is a different event, not a negative purchase), the currency is not three letters, the quantity is negative, or parameters contains any of value, currency, order_id, quantity or product_id — pass those as arguments instead.
Pass orderId
It is what Google deduplicates conversions on, and how you reconcile a forwarded conversion against your own records.
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>['spring', 'sale'],
),
);
if (result.success) {
final url = result.url;
} else {
final code = result.errorCode;
final message = result.errorMessage;
}tags is a List, not a Map
DeeplinklyLinkOptions.tags is List<String>. It was previously typed as a map, which the API never accepted — it understands only a list (or a comma-separated string), so every map sent was silently discarded server-side. If your integration passes a map, the tags are not reaching us.
// Dev / staging only
await FlutterDeeplinkly.setDebugMode(true);| 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. |
| setUserData({ ...fields, customData }) | Future<bool> | Merges the conversion-matching fields, plus your own customData ids. All-or-nothing: a malformed field stores none of them. |
| clearUserData() | Future<void> | Erases everything setUserData and setUserId recorded, here and on Deeplinkly's servers. Re-sent until delivered. |
| logEvent(name, { parameters }) | Future<bool> | Validated custom event. Returns false when the native layer rejects it; no network call is made on rejection. |
| logPurchase({ value, currency, ... }) | Future<bool> | Typed wrapper over logEvent. Sends the `purchase` event under the one spelling Meta and Google are both built from. |
| generateLink({ content, options }) | Future<DeeplinklyResult> | Builds a campaign link. Inspect `success`, `url`, and `errorCode` / `errorMessage`. |
| setAttributionLevel(level) | Future<bool> | Restricts what enrichment is sent. Each level is a strict subset of the one above it. |
| getAttributionLevel() | Future<DeeplinklyAttributionLevel> | The level currently in force, whether set natively or from Dart. |
| willShowPasteboardBanner() | Future<bool> | iOS. Whether a read would surface the system banner. Reads no content and shows no banner itself. |
| checkPasteboardNow() | Future<bool> | iOS. Performs the deferred pasteboard read on demand, for priming your own explanation first. |
| setCheckPasteboardOnInstall(bool) | Future<bool> | iOS. Runtime override of the automatic read. To disable it, use Info.plist instead — this arrives too late. |
| setPIIHashingEnabled(bool) | Future<bool> | SHA-256 the email, phone and names on the device before they are sent. Off by default; nothing is hashed on the Dart side. |
| isPIIHashingEnabled() | Future<bool> | Whether on-device hashing is on. |
| setDebugMode(bool enabled) | Future<void> | Enables verbose native logging for integration debugging. |
- 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 (click id + path + query hash).
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://deeplink/product/123" com.your.appVerify cold start (process killed) and warm start (app backgrounded) separately. Two constraints worth planning around:
- Android: the Install Referrer is unavailable on sideloaded builds — test deferred flows through Play internal testing.
- iOS: deferred deep linking cannot be tested on the Simulator (no App Store). The read is once per install and clears the pasteboard, so reinstall to retest — relaunching will not repeat it.
No event on stream
Confirm init() ran before the first listener. Re-check that MainActivity / the scene opens correctly for the link type (scheme vs universal link), and that the API key uses the exact per-platform name.
Attribution always empty
Read after splash; on first open the native store may not be ready. Check the attribution level — none sends no enrichment, so it looks empty server-side even when the link resolved correctly.
Deferred does nothing on iOS
Confirm the link host is listed in DeeplinklyLinkDomains; an unlisted custom domain is ignored by design. Then confirm you are testing on a real device with a genuinely fresh install.
Every link opens twice
Almost always AppDelegate link-forwarding left over from an older integration. The plugin registers for the delegate and scene callbacks itself — remove yours.
logEvent returns false
Check name length, param count, key length, and string value length. Keys starting with _dl_ are reserved and rejected.
generateLink errors
Read errorCode: NULL_NATIVE_RESPONSE, PLATFORM_EXCEPTION, or a native-provided code. Ensure the API key and network allow the native call.
For AI agents
The Flutter skill is a runbook with intent detection, a codebase scan phase, batched discovery questions, the exact platform-key contract, a validation matrix, and a troubleshooting decision tree — enough for Claude Code, Codex, or Cursor to implement Deeplinkly with minimal back-and-forth.
Repository & more docs
Extended README, the full markdown guide, the signal-by-signal reference, and the agent skill file live in the open-source package: README.md, docs/FLUTTER_SDK.md, docs/SIGNALS.md, .cursor/skills/flutter-sdk/SKILL.md.