becklinks_flutter
Deep links, deferred deep links and install attribution for Flutter apps on Android and iOS, with Beck Links.
- Direct deep links: Universal Links (iOS) and App Links (Android) on your project's
becklinks.comhost open the right screen, on cold and warm start. - Deferred deep links: a user who clicks a link, installs the app and opens it lands on the
linked screen. Android uses the Google Play Install Referrer; iOS uses an opt-in pasteboard token.
Every match says how it was made (
matchMethod) and how sure it is (confidence). - Attribution and events: which link brought an install, plus custom events (purchases, sign-ups) with an offline queue, shown in the Beck Links dashboard.
- Share links: create referral and share links from the app.
- Privacy first: no IDFA, no GAID, no device fingerprinting, no tracking prompt. See Privacy.
Requirements
| What | Minimum |
|---|---|
| Flutter / Dart | Flutter 3.27 (Dart 3.6) |
| iOS | iOS 15 (app deployment target 15.0); Xcode 16 or newer (the plugin compiles in Swift 5.9 language mode) |
| Android | Android 6.0 (API 23; app minSdk 23 or higher), compileSdk 35, Kotlin Gradle plugin 1.8 or AGP 9 |
| Platforms | Android and iOS only |
| Beck Links | A project with your iOS and/or Android app configured, and its publishable key |
| Native code to add | None: the plugin registers itself; you only do the platform setup below |
Install
Add the package from pub.dev:
flutter pub add becklinks_flutter
This adds becklinks_flutter: ^0.3.1 to your pubspec.yaml. To try an unreleased change, depend on
the repository instead:
dependencies:
becklinks_flutter:
git:
url: https://github.com/Tecbeckp/becklinks-flutter.git
ref: main
Quick start (5 minutes)
1. In the Beck Links dashboard
- Open your project in the environment you develop against (
test). - Under Applications, enter your iOS app (bundle ID, Apple Team ID, App Store ID) and your
Android app (package name, SHA-256 fingerprints, Play Store URL). Beck Links then serves
apple-app-site-associationandassetlinks.jsonon your link hosts. - Note your link hosts (live
{slug}.becklinks.com, test{slug}-test.becklinks.com) and copy the publishable key (pk_test_…for development,pk_live_…for release builds). - Create a link whose deep-link path is a route of your app, for example
/product/123.
The examples below use the slug acme and the app ID com.acme.shop; replace them with yours.
2. Platform setup
Do the iOS and Android steps. The minimum is the iOS 15.0 deployment target,
the Associated Domains entitlement and FlutterDeepLinkingEnabled = NO on iOS, and minSdk 23,
the autoVerify intent filter and flutter_deeplinking_enabled = false on Android.
3. Configure the SDK and route links in one place
import 'package:becklinks_flutter/becklinks_flutter.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
final router = GoRouter(
routes: [
GoRoute(path: '/', builder: (context, state) => const HomeScreen()),
GoRoute(
path: '/product/:id',
builder: (context, state) => ProductScreen(id: state.pathParameters['id']!),
),
],
);
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// Returns at once; storage, the launch link and network work run in the background.
await BeckLink.instance.configure(apiKey: 'pk_test_YOUR_KEY');
// The one place that routes Beck Links links: the link that launched the app, the deferred link
// after an install, and links opened while the app runs. Each arrives once.
BeckLink.instance.onLink.listen(routeLink);
runApp(MaterialApp.router(routerConfig: router));
}
/// Opens the screen of [link]. Link paths come from outside the app, so only known routes pass;
/// anything else keeps the current screen.
void routeLink(LinkEvent link) {
final uri = Uri.tryParse(link.path);
final route = switch (uri?.pathSegments) {
['product', final id] => '/product/${Uri.encodeComponent(id)}',
_ => null,
};
if (route != null) router.go(route);
}
onLink keeps the newest links until the first listener subscribes, so nothing is lost between
configure() and listen().
4. Attribution and events
final attribution = await BeckLink.instance.getAttribution();
if (attribution.state == AttributionState.attributed) {
debugPrint('Installed from campaign ${attribution.campaign?.name}');
}
await BeckLink.instance.track('add_to_cart', properties: {'sku': 'SKU-123', 'quantity': 2});
await BeckLink.instance.track('purchase', revenue: 49.98, currency: 'USD');
5. Verify it works
- Run a debug build with
configure(apiKey: …, logLevel: LogLevel.debug). - Open your test link on the device:
- Android:
adb shell am start -W -a android.intent.action.VIEW -d "https://acme-test.becklinks.com/abc123" com.acme.shop(the package name at the end skips the App Links check; leave it out to test verification too). - iOS: paste the link into Notes or Messages and tap it (a link typed into Safari never opens an app).
- Android:
- The app opens on the linked screen and the console shows
[BeckLink] INFO: Delivering LinkEvent(url: https://acme-test.becklinks.com/abc123, path: /product/123, …). - Your tracked events appear on the dashboard's Events page within about a minute.
If something does not work, see Troubleshooting.
Platform setup
Each environment has its own link host: links of test are on {slug}-test.becklinks.com and work
only with a pk_test_… key, links of live are on {slug}.becklinks.com and work only with a
pk_live_… key. Enter your app details in every environment you use.
iOS
Deployment target iOS 15.0. The plugin needs iOS 15, and Flutter's app template targets an older version (12.0 or 13.0, depending on the Flutter version). Raise your app's target:
-
In Xcode, open
ios/Runner.xcworkspace, select the Runner target, then General → Minimum Deployments → iOS 15.0 (this setsIPHONEOS_DEPLOYMENT_TARGET = 15.0inios/Runner.xcodeproj/project.pbxproj). -
CocoaPods apps (Flutter 3.27–3.43, or newer Flutter with Swift Package Manager turned off): in
ios/Podfile, uncomment the platform line and set it to 15.0:platform :ios, '15.0' -
Swift Package Manager apps (Flutter 3.44 or newer): step 1 is enough. Flutter copies your deployment target into its generated plugin package on the next build; run
flutter build ios --config-onlyto apply it at once.
Associated Domains. In Xcode, open ios/Runner.xcworkspace, select the Runner target, then
Signing & Capabilities → + Capability → Associated Domains, and add both hosts. This
writes ios/Runner/Runner.entitlements:
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:acme.becklinks.com</string>
<string>applinks:acme-test.becklinks.com</string>
</array>
Check that the Debug, Profile and Release configurations all use this entitlements file
(CODE_SIGN_ENTITLEMENTS) and that your App ID has Associated Domains enabled. For development
builds you can add ?mode=developer to a host (applinks:acme-test.becklinks.com?mode=developer)
to bypass Apple's CDN cache; this needs Developer Mode on the device and Settings → Developer →
Associated Domains Development turned on.
Turn off Flutter's own deep linking, so Flutter's router does not handle the same URL a second
time (Flutter turns it on by default from 3.27). In ios/Runner/Info.plist:
<key>FlutterDeepLinkingEnabled</key>
<false/>
Custom URL scheme (optional). If you set a custom URI scheme for your iOS app in the dashboard,
the "Open in app" button of Beck Links' page for in-app browsers uses it
(acmeshop://becklink?url=…). Register the same scheme in Info.plist:
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.acme.shop.becklink</string>
<key>CFBundleURLSchemes</key>
<array>
<string>acmeshop</string>
</array>
</dict>
</array>
Pasteboard deferred links (optional). For deferred deep links on iOS, turn on pasteboard deferred linking in the dashboard and opt in in the app:
await BeckLink.instance.configure(apiKey: 'pk_live_YOUR_KEY', enablePasteboard: true);
What the user sees:
- They tap a link without the app installed. Beck Links' landing page shows a Get the app
button; tapping it copies a click URL (
https://acme.becklinks.com/_c/{click ID}) and opens the App Store. - They install and open the app. On that first run only, and only when iOS reports that the pasteboard probably holds a web URL, the SDK reads the pasteboard once. iOS 16 and later then ask whether the app may paste from Safari, with Allow Paste and Don't Allow Paste; iOS 15 shows a short "pasted from" banner instead.
- With Allow Paste the app opens the linked screen (
matchMethodpasteboard,confidencecertain). With Don't Allow Paste the install counts as organic and the app shows its normal first screen.
The SDK only uses a click URL of your project environment; anything else on the pasteboard is neither sent nor kept, and the SDK never writes or clears the pasteboard. If the pasteboard holds some other web URL (say, a copied news link), iOS still shows the prompt on that first run. Users can change their answer later in Settings → your app → Paste from Other Apps. The SDK does not read the pasteboard when a link opened the app, or while tracking is off.
CocoaPods and Swift Package Manager. The plugin supports both. On Flutter 3.27–3.43 use CocoaPods (the default there): the Swift package needs Flutter 3.44 or newer, so do not turn on Flutter's Swift Package Manager support on older Flutter versions.
Android
minSdk 23. The plugin needs Android 6.0 (API 23). Flutter's app template sets
minSdk = flutter.minSdkVersion in android/app/build.gradle (or build.gradle.kts); that value
is 21 on Flutter 3.27 and 24 on Flutter 3.44. If your Flutter's default or your own value is below
23, set it in defaultConfig (the same line works in Groovy and in Kotlin DSL):
android {
defaultConfig {
minSdk = 23
}
}
Do not lower a value that is already 23 or higher: Flutter's own minimum can be above the plugin's.
App Links intent filter. In android/app/src/main/AndroidManifest.xml, inside the
<activity> of MainActivity (keep the template's android:launchMode="singleTop"), turn off
Flutter's own deep linking (on by default from Flutter 3.27) and add one filter per host:
<meta-data android:name="flutter_deeplinking_enabled" android:value="false" />
<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="acme.becklinks.com" />
</intent-filter>
<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="acme-test.becklinks.com" />
</intent-filter>
Android 11 and older verify all autoVerify hosts of an app together: if the test host's
assetlinks.json lacks the certificate of a build, the live host is not verified for that build
either. Add every signing certificate to both environments in the dashboard, or move the test-host
filter into android/app/src/debug/AndroidManifest.xml (inside the same
<activity android:name=".MainActivity">) so release builds declare only the live host.
SHA-256 fingerprints. assetlinks.json must list the certificate that signed the installed
build. Add each one in the dashboard's Android app settings:
| Build | Where to find the SHA-256 |
|---|---|
| Installed from Google Play | Play Console → your app → App integrity → App signing key certificate |
| Signed with your upload key | keytool -list -v -keystore upload-keystore.jks -alias upload |
Debug builds (flutter run) |
.\gradlew signingReport (Windows) or ./gradlew signingReport in android/ |
Devices see the Play App Signing key. Registering only the upload key is the most common reason App Links open the browser.
Custom scheme (optional). If you set a custom scheme for your Android app in the dashboard, add
a filter for it (without autoVerify):
<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="acmeshop" android:host="becklink" />
</intent-filter>
The plugin itself declares the INTERNET permission and visibility of the Play Store app (for the
install referrer). It does not use the Advertising ID and adds no AD_ID permission.
Check verification on a device or emulator:
adb shell pm get-app-links com.acme.shop
adb shell pm verify-app-links --re-verify com.acme.shop
Handling links
-
Handle links in one place.
onLinkdelivers every link once: the one that launched the app, the deferred link on the first run, and links opened while the app runs.getInitialLink()returns that same launch event again. UseonLinkalone (as in the quick start), or callgetInitialLink()from a splash screen and skip that event ononLink:final initial = await BeckLink.instance.getInitialLink(); // waits at most firstOpenTimeout BeckLink.instance.onLink.where((link) => link != initial).listen(routeLink);Do not await
getInitialLink()beforerunApp(): the SDK resolves the launch link once the app is visible, so it would hold up your first frame. -
Treat link content as untrusted. Anyone can craft a link URL, and link data is readable by anyone with the link and your publishable key. Route only to known paths and never put secrets in link data.
-
Offline links. When the service does not answer within
firstOpenTimeout, the app still gets the link, built on the device:linkIdisnull,datais empty, andpathis the link's short path (/abc123), not its deep-link path. Route such events to a sensible default. -
Only Beck Links URLs. Only URLs of the configured environment's link hosts (and the custom-scheme form above) arrive on
onLink. The SDK ignores your own deep links and OAuth callbacks, never sends them anywhere, and leaves them to other plugins. -
Inactive links. Links that are expired, disabled, archived or deleted are not delivered; the SDK logs "The link is not active in this project environment".
LinkEvent fields: url, path (the in-app route), params (query parameters), data (custom
JSON), isDeferred, matchMethod, confidence, linkId, campaign, resource, clickedAt.
Links to every item (URL templates)
An app with thousands of items (challenges, products, profiles) does not need a link per item. In
the dashboard you define a URL pattern such as https://acme.becklinks.com/c/{id}; every item then
has a working link, and LinkEvent.resource tells you which one opened the app:
void routeLink(LinkEvent link) {
final item = link.resource; // LinkResource(type: 'challenge', id: '4521'), or null
if (item != null && item.type == 'challenge') {
openChallenge(item.id); // fetch it through your own API
return;
}
// ... ordinary links: route by link.path
}
pathalready holds the template's in-app route with the id filled in (/challenge/4521), so an app that routes bypathneeds no change.resource.idis the value from the URL, percent-decoded and at most 128 characters. Beck Links checks that it fits the format the template allows (digits, UUID, base64url, …); it does not know whether the item exists or the user may see it, so load it through your own API and handle "not found".- Template links also work after install: a deferred match returns the same
resource. resourceisnullfor ordinary links, and for the offline event the SDK builds on the device (the service could not be reached); route those to a sensible default.
Apps that already handle deep links (app_links, go_router, GetX)
An app that has its own deep-link handling does not have to give it up. Two public members make the SDK work next to it:
bool BeckLink.instance.isBeckLinkUri(Uri uri): synchronous, offline and side-effect free.truefor a link of the configured environment (https://{slug}.becklinks.com/…for apk_live_key,https://{slug}-test.becklinks.com/…for apk_test_key, a custom link host the service sent to the SDK, and thescheme://becklink?url=…form). Hosts are compared exactly:https://evil.com/?x=acme.becklinks.comandhttps://acme.becklinks.com.evil.com/are not Beck Link URIs. Beforeconfigure()ran, the environment is unknown and any platform host (*.becklinks.com, live or test) counts; custom hosts count once the SDK opened its storage.Future<bool> BeckLink.instance.handleUri(Uri uri): gives the SDK a URI your app received by other means (app_links, ago_routerredirect, a push payload, a QR scan). It resolves it like a link opened by the system (the same re-engagement call and offline fallback) and delivers oneLinkEventononLink. It returnstruewhen the URI is a Beck Links URI that the SDK took, andfalseotherwise (nothing is sent, stored or logged beyond scheme and host; continue with your own handling). It never throws.
Rules that apply to handleUri:
- Once per open. The same URL (scheme and host case-insensitive, the rest exact) is delivered
once when it arrives again within 10 seconds, whether through
handleUritwice or through the platform channel andhandleUri, in either order. Opening the same link again later is delivered again. The window is in memory (it does not survive a restart). - Early calls are safe. While the SDK starts, a forwarded URI waits for storage and for the
launch link, then follows the same order as links opened while the app runs. Before
configure()at most 16 Beck Links URIs are kept and handled when it runs. - Consent. As for platform links, a forwarded link is still resolved and delivered while tracking
is off, without any identifier (
setTrackingEnabled(false)). - Privacy. Logs show scheme, host and the short path of Beck Links URLs only, never query values.
Pattern A: keep platform links on, filter your own listener
The simplest change. The SDK keeps reading links from the system; your listener skips Beck Links URIs so they are not handled twice:
final appLinks = AppLinks();
appLinks.uriLinkStream.listen((uri) {
if (BeckLink.instance.isBeckLinkUri(uri)) return; // the SDK delivers it on onLink
Get.toNamed(uri.path, parameters: uri.queryParameters); // GetX; or router.go(...)
});
Handle the Beck Links links through onLink, as in the quick start. For the launch link do not use
appLinks.getInitialLink() for Beck Links URIs: filter it the same way.
Pattern B: you receive every URI, the SDK receives none from the system
Turn the SDK's own link intake off and forward everything. Your listener stays the single entry point for deep links:
await BeckLink.instance.configure(
apiKey: 'pk_live_YOUR_KEY',
handlePlatformLinks: false, // the SDK reads no launch or incoming link itself
);
final appLinks = AppLinks();
Future<void> onUri(Uri uri) async {
if (await BeckLink.instance.handleUri(uri)) return; // a Beck Links URI: the SDK took it
Get.toNamed(uri.path, parameters: uri.queryParameters); // your own links
}
final initial = await appLinks.getInitialLink();
if (initial != null) await onUri(initial);
appLinks.uriLinkStream.listen(onUri);
With go_router, call handleUri in the listener that feeds router.go (as above), not inside
redirect, which must stay a pure function.
With handlePlatformLinks: false, the SDK does not call the native layer for the launch link and does
not listen to the native link stream (the native code only observes system callbacks, never claims
them, so it does not interfere with app_links or other plugins). getInitialLink() reports only
the deferred link of a first run; every forwarded URI, the launch link included, arrives on
onLink. Deferred deep linking (Play Install Referrer, the iOS pasteboard), the first open,
attribution and events are not links of the system and work as before. The option is read once, at
the first configure() of the process.
Pattern C: Flutter's own deep linking (flutter_deeplinking_enabled)
FlutterDeepLinkingEnabled (iOS Info.plist) and flutter_deeplinking_enabled (Android manifest)
only decide whether Flutter's router also receives the URL (as the initial route or a pushed
route). They have no effect on whether the SDK or app_links receive it: native callbacks reach
both plugins either way.
- Leave it on only if your own router can safely receive a Beck Links URL as well. Then the same
URL is seen twice: once by Flutter's router and once by the SDK (or by
app_linksandhandleUri). The router sees a route such as/summer24; unless it ignores it, the user sees an unknown-route page or a double navigation. Make the router ignore it (GetX: anunknownRoutethat shows the current page;go_router: aredirectthat returns the current location forisBeckLinkUri; depending on the Flutter version the router sees the whole URL or only the path, so a host check is not always possible). - Turn it off (as in Platform setup) when the router cannot ignore those
routes. Then only
app_linksand the SDK see the URL, and Pattern A or B decides who handles it. This is the safe choice, and the one this README recommends for new apps. - Neither value makes the SDK deliver a link twice: the SDK delivers one
LinkEventper open as described above.
This section describes the intended behaviour from the SDK's code and its tests; the combinations of
Flutter versions, app_links and routers were not exercised on devices here, so verify your own
combination with debugOpenLink and a real link before release.
Deferred deep linking
What each platform allows, without guessing:
| Platform | How | Confidence | When it works |
|---|---|---|---|
| Android | Google Play Install Referrer (matchMethod install_referrer) |
certain |
The app was installed from Google Play after the click; not for sideloads or other stores |
| iOS | Pasteboard click URL, opt-in (matchMethod pasteboard) |
certain |
enablePasteboard: true, the user tapped the landing page's Get the app button and allowed paste |
| Both | No fingerprinting, no IP matching, no IDFA | — | In every other case the install is organic and no deferred link is delivered |
- The match happens on the first open after install, within the project's deferred window (7 days by default). A click is matched to one install only.
getInitialLink()waits for the answer at mostfirstOpenTimeout(3 seconds by default, up to 30), counted from when the request is sent; the time a user takes to answer the paste prompt does not count. After the timeout the app gets "no deferred link". A deferred link that matches later is not delivered toonLink; onlyonAttributionupdates.- The deferred link is delivered once per install, also when the install is a reinstall.
Test on Android: upload a build to a Play internal testing track and join it as a tester.
Uninstall the app, tap a link on the device (it opens the Play Store), install from Play and open
the app. Builds installed with adb install or flutter run have no install referrer.
Test on iOS: turn on pasteboard deferred linking and pass enablePasteboard: true. Uninstall
the app, tap a link in Safari, tap Get the app, then install your build (TestFlight or Xcode)
and open it; allow paste when asked. The install ID survives a reinstall on the same device, so a
repeated test reports reinstall, but it still delivers the deferred link.
Attribution
getAttribution() says which link, if any, brought this install. state is attributed,
organic, reinstall, pending (the first open's answer is still on its way) or unavailable
(tracking is off, or no result could be had). On later launches it answers at once from the stored
result. onAttribution emits every change in this process, for example pending and then
attributed.
Events and user ID
await BeckLink.instance.setUserId('user_8841'); // after sign-in; use an internal ID
await BeckLink.instance.track('signup');
await BeckLink.instance.clearUserId(); // on sign-out
await BeckLink.instance.flush(); // optional: send now; waits at most 30 s
- Names: 1–64 characters of
a-z,0-9and_. Standard names:signup,login,purchase,subscription,checkout,add_to_cart,registration. - Properties: flat, at most 50 keys and 8 KB, values
String,boolornum;nullvalues are dropped. The service removes values that look like email addresses or phone numbers unless your project allows them. revenueneedscurrency(ISO 4217, such asUSD), andcurrencyneedsrevenue.- Events are stored on the device and sent in batches every 15 seconds (your project's remote settings can change this), at 20 waiting events and when the app goes to the background. Offline, up to 500 events (2 MB) are kept for 7 days and sent when the network is back; the service stores each event once.
Share links
try {
final url = await BeckLink.instance.createLink(
LinkOptions(
deepLinkPath: '/referral',
data: {'reward': 'free_month'},
campaign: 'referral',
source: 'app',
medium: 'share',
),
);
// Hand `url` to your share sheet.
} on BeckLinkException catch (error) {
// network, timeout, rate_limited, invalid_key or invalid_request; see the error codes below.
debugPrint('The link was not created: ${error.code.wireValue}');
}
Links created from the app use your environment's link host, a generated short path and the
project's default redirects: a publishable key is public, so it cannot choose where a link leads.
When a user ID is set, the SDK adds it to the link's data as referrer_user_id.
Consent
Tracking is on until the app turns it off. To wait for consent, pass the user's choice right after
configure(); the SDK keeps it across launches:
await BeckLink.instance.configure(apiKey: 'pk_live_YOUR_KEY');
await BeckLink.instance.setTrackingEnabled(userHasConsented);
While tracking is off, links still open and route (they are resolved without any identifier), but
the SDK sends no first open, sessions or events, deletes queued events, deletes the install ID (on
iOS also the Keychain copy), drops track() calls silently and reports attribution unavailable.
Turning tracking on again makes the device a new install. If tracking is turned on only after the
first screen appeared, that install's deferred link is not delivered (see
Known limitations).
Logging
logLevel (none, error by default, info, debug) sets what the SDK writes to the console,
with the prefix [BeckLink]; change it later with setLogLevel(). Your project's remote settings
can override it. Logs never contain API keys, user IDs, query strings or event properties.
Debugging and diagnostics
For debug and support screens (the example app uses all of them):
BeckLink.version: the SDK version sent asX-SDK-Version.getDiagnostics(): aBeckLinkDiagnosticssnapshot with the environment, API origin, install ID, first-open status and outcome (matchedLinkEvent,unmatchedReason), attribution, remote config (linkHosts) and queued event count. No API key or user ID.onApiCall: aBeckLinkApiCallfor every request attempt: path, attempt, HTTP status, duration,X-Request-Id,Idempotent-Replayedand the error code of a failed attempt. Never the key, bodies or IDs. May be listened to beforeconfigure().configure(apiBaseUrl: …): talk to a staging or local SDK API instead ofhttps://api.becklinks.com. HTTPS, or HTTP to this machine (localhost,10.0.2.2from the Android emulator); debug builds also accept HTTP to a private network address (192.168.x.x) so a phone can reach a server on the same Wi-Fi. Leave it out in apps.- Debug builds only (they throw
UnsupportedErrorelsewhere):debugResetInstall()forgets the install,debugRunFirstOpen()sends the first open again without a restart, anddebugOpenLink(uri)handles a URL as if the system had opened the app with it.
Example app
example/ is a go_router app with home, product and referral screens and a Debug screen
for testing links, events and attribution. It doubles as a debugging app for a real phone against
production or a local server; see example/DEBUGGING.md. It uses the current Flutter app template and needs
Flutter 3.44 or newer; the package itself supports Flutter 3.27 and newer.
API reference
The full reference is in the package's dartdoc. Summary:
| Member | What it does |
|---|---|
BeckLink.instance |
The SDK |
Future<void> configure({required apiKey, logLevel, enablePasteboard, firstOpenTimeout}) |
Starts the SDK with a publishable key and returns at once; call it in the main isolate |
Future<LinkEvent?> getInitialLink() |
The link that opened the app (direct or deferred), or null |
bool isBeckLinkUri(Uri uri) |
Whether uri is a Beck Links URI of the configured environment (synchronous, offline) |
Future<bool> handleUri(Uri uri) |
Feeds a URI your app received by other means to the SDK; false when it is not ours |
configure(…, handlePlatformLinks: true) |
false: the SDK reads no links from the system; you forward them with handleUri |
Stream<LinkEvent> get onLink |
Every link, each once; buffered until the first listener |
Future<Attribution> getAttribution() |
The install's attribution |
Stream<Attribution> get onAttribution |
Attribution changes |
Future<void> track(name, {properties, revenue, currency}) |
Queues a custom event |
Future<void> setUserId(id), Future<void> clearUserId() |
Sets or forgets your user ID (1–256 characters) |
Future<String> createLink(LinkOptions options) |
Creates a share link and returns its URL |
Future<void> setTrackingEnabled(bool enabled) |
Consent switch, kept across launches |
Future<void> flush() |
Sends queued events now |
void setLogLevel(LogLevel level) |
Changes the console log level |
| Type | Contents |
|---|---|
LinkEvent |
url, path, params, data, isDeferred, matchMethod, confidence, linkId, campaign, resource, clickedAt |
LinkResource |
type (resource type key), id (the item's ID from the URL) |
Attribution |
state, matchMethod, confidence, linkId, campaign, installedAt |
Campaign |
name, source, medium, content, term, creative |
LinkOptions |
deepLinkPath, data, campaign (a campaign key), source, medium, content, term, creative, expiresAt |
AttributionState |
pending, attributed, organic, reinstall, unavailable |
MatchMethod |
universalLink, appLink, uriScheme, installReferrer, pasteboard; idfa and probabilistic are never reported |
Confidence |
certain; low is never reported |
LogLevel |
none, error, info, debug |
BeckLinkException |
code (a BeckLinkErrorCode), message, statusCode, retryAfter, requestId |
RemoteConfig |
Settings the service sends to the SDK; apps do not need it |
Mistakes in arguments (an event name with capitals, LinkOptions.data over 4 KB, a path without a
leading /) throw an ArgumentError. Everything else is a BeckLinkException; branch on code,
not on message:
code |
Thrown by | What to do |
|---|---|---|
not_configured |
Any other method called before configure(), except the onLink and onAttribution getters |
Call configure() first |
invalid_key |
configure() (not a pk_test_…/pk_live_… key; secret keys are refused), createLink() |
Use the publishable key; check it is active and allowed to create links |
network |
createLink() |
Offer a retry |
timeout |
createLink() (after at most 30 seconds) |
Offer a retry |
rate_limited |
createLink() |
Wait retryAfter, then retry |
invalid_request |
createLink(), for example with an unknown campaign key |
Fix the request; message names the problem |
tracking_disabled |
Not thrown by this version: track() drops events while tracking is off |
— |
link_not_found |
Not thrown: inactive links are not delivered, and the SDK logs them | — |
track(), flush() and link handling never throw for network trouble: events stay queued, and
links fall back to the event built on the device.
Troubleshooting
Set logLevel: LogLevel.debug first; most problems show up in the [BeckLink] log lines. The
dashboard's domain diagnostics and Link Tester check the hosted files for you.
| Symptom | Cause | Fix |
|---|---|---|
Android build fails: uses-sdk:minSdkVersion 21 cannot be smaller than version 23 declared in library [:becklinks_flutter] (Flutter: "The plugin becklinks_flutter requires a higher Android SDK version") |
The app's minSdk is below 23 (flutter.minSdkVersion is 21 on Flutter 3.27) |
Set minSdk = 23 in android/app/build.gradle (or .kts); see Android |
iOS build fails in pod install: "… required a higher minimum deployment target" (Flutter: The plugin "becklinks_flutter" requires a higher minimum iOS deployment version) |
The Podfile's platform :ios (or, without one, the Runner target's deployment target) is below 15.0 |
Set platform :ios, '15.0' in ios/Podfile and Minimum Deployments iOS 15.0 on the Runner target; see iOS |
iOS build fails with Swift Package Manager: The package product 'becklinks-flutter' requires minimum platform version 15.0 for the iOS platform, but this target supports 13.0 |
The Runner target's deployment target is below 15.0 | Set Minimum Deployments iOS 15.0 on the Runner target, then run flutter build ios --config-only |
| iOS: the link opens Safari or the App Store, not the app | Wrong Apple Team ID or bundle ID in the dashboard, or the Associated Domains entitlement is missing from this build | Fix the iOS app settings in the dashboard; check the entitlement in Release builds and the capability of your App ID |
| iOS: a link typed or pasted into Safari's address bar opens the website | By design: iOS opens apps only for tapped links | Tap the link in Notes or Messages instead |
| iOS: tapping a link on a web page of the same host opens the website | By design: iOS ignores Universal Links within the same domain | Link from another domain; Beck Links' landing page offers an "Open in app" button |
| iOS: links stopped opening the app on one device | The user once chose to open such a link in Safari, which iOS remembers | Long-press the link and choose to open it in the app, or tap Open in Safari's app banner |
| iOS: a changed app setting has no effect yet | Apple's CDN still serves the old apple-app-site-association |
Wait (it can take hours); compare with curl https://app-site-association.cdn-apple.com/a/v1/acme.becklinks.com; use ?mode=developer in development builds |
| Android: the link opens the browser (Android 12+) or a chooser (Android 6–11) | assetlinks.json lacks the SHA-256 of the installed build, often because only the upload key is registered |
Add the Play App Signing (and debug) SHA-256 in the dashboard, reinstall, run adb shell pm verify-app-links --re-verify com.acme.shop |
| Android 6–11: debug builds open links, release builds do not | The test host is declared, but its environment lacks the release certificate (all hosts verify together) | Add the certificate to the test environment, or declare the test host only in the debug manifest |
| The app opens but shows Flutter's "page not found", or navigates twice | Flutter's own deep linking is still on (the default since Flutter 3.27) | Set FlutterDeepLinkingEnabled to NO (iOS) and flutter_deeplinking_enabled to false (Android) |
| The link stays in the in-app browser of Instagram, Facebook, TikTok or Gmail | These browsers do not hand links to apps | Expected: Beck Links shows an "Open in app" page there. Set a custom scheme in the dashboard and the app so its button can open the app |
The app opens but onLink gets nothing; log "Ignored a link of the … environment" |
A test link with a live key, or the reverse | Use links and the publishable key of the same environment |
The app opens but onLink gets nothing; log "The link is not active…" |
The link is expired, disabled, archived or deleted | Reactivate it or use another link; keep a default screen for this case |
linkId is null, data is empty and path is the short path |
The service did not answer within firstOpenTimeout, or the device was offline |
Check connectivity; raise firstOpenTimeout on slow networks; route unknown paths to a default screen |
Android: no deferred link, attribution organic |
The app was not installed from Google Play after the click (adb, flutter run, another store) |
Test through a Play internal testing track (see Deferred deep linking) |
iOS: no deferred link, attribution organic |
enablePasteboard is off, pasteboard deferred linking is off in the dashboard, paste was denied, or the link is on a custom domain |
Check each; the user must tap Get the app on the landing page and allow paste |
No deferred link, but onAttribution later reports attributed |
The first-open answer arrived after firstOpenTimeout |
Raise firstOpenTimeout (at most 30 seconds) or check the network |
| No deferred link after the user accepted tracking | Tracking was off at the first launch and was turned on after the first screen appeared | Known limitation; see Consent |
configure() throws invalid_key |
A secret key (sk_…) or a malformed key |
Use the publishable key (pk_test_… or pk_live_…) from the dashboard |
| Log "The Beck Links API refused the API key"; nothing is sent | The key is unknown or revoked; the SDK stops sending until the app restarts or configure() gets another key |
Ship the current publishable key; queued events are kept |
createLink() throws rate_limited, or the log says sending was rate limited |
Too many requests from this install | Wait BeckLinkException.retryAfter; queued events are sent again automatically |
| iOS on Flutter 3.27–3.37: no links at all | The app adopted the UIScene life cycle, whose events plugins only get from Flutter 3.38 | Keep the app-delegate life cycle on these Flutter versions, or upgrade Flutter |
Check the hosted files yourself; expect status 200, Content-Type: application/json and no
redirect:
curl -i https://acme.becklinks.com/.well-known/apple-app-site-association
curl -i https://acme.becklinks.com/.well-known/assetlinks.json
Known limitations
- iOS on Flutter 3.27–3.37 with UIScene: apps that adopted the UIScene life cycle themselves receive no links, because Flutter gives plugins scene events only from 3.38. Apps on the default app-delegate life cycle are not affected.
- Another plugin claiming the launch: in UIScene apps on Flutter 3.38+, if another plugin
returns
truefromscene(_:willConnectTo:options:), the link that launched the app can be missed. Links opened while the app runs are not affected. - Late deferred links are not delivered: a deferred link that matches after
firstOpenTimeoutonly updates the attribution. - iOS pasteboard links on custom domains: on the first run the SDK knows only the
*.becklinks.comhosts, so it does not use a pasteboard click URL on a custom domain. - Consent later than the first screen: turning tracking off deletes the install ID, and turning it on starts a new install; a deferred link that this new install matches is not delivered.
- Main isolate only: call
configure()in the app's main isolate. In background isolates and headless engines (for example push-messaging handlers) the SDK reads no launch link and registers no install. - Only Beck Links URLs: the SDK reports Beck Links URLs only. Your app's other deep links need
their own handling (for example
app_links), which can live next to the SDK: see Apps that already handle deep links (isBeckLinkUri,handleUri,handlePlatformLinks). Forwarded URIs are deduplicated for 10 seconds only, and the pattern combinations are not verified on devices. - Reinstalls: iOS keeps the install ID in the Keychain, so a reinstall on the same device is
reported as
reinstall; a new device or a restored backup is a new install. On Android, reinstall detection is best effort: a reinstall or cleared app data usually counts as a new install. - Install referrer: Google Play only; Galaxy Store, Huawei AppGallery and sideloaded installs get no deferred link.
- Swift Package Manager: the Swift package needs Flutter 3.44+; use CocoaPods on older Flutter.
Privacy
The SDK collects only what deep linking, attribution and your events need. It never collects the
IDFA, the GAID, device names or fingerprints, never shows the App Tracking Transparency prompt, and
needs no AD_ID permission.
The plugin ships a PrivacyInfo.xcprivacy that declares Device ID, Product Interaction, User ID,
Purchase History, Coarse Location and Other Diagnostic Data (all linked to the user, none used for
tracking), so Xcode's privacy report lists all six. In App Store Connect and in Google Play's Data
safety form, declare User ID only if you call setUserId(), and Purchase History only if
you track purchases or revenue; the other types apply to every app that uses the SDK.
doc/privacy.md lists exactly what is sent to which endpoint, what is stored on the device, the App Privacy and Data safety answers, and data retention.
Releasing
For maintainers. GitHub Actions publishes the package to pub.dev
(.github/workflows/publish.yml) with pub.dev's automated publishing: a short-lived
GitHub OIDC token, no pub.dev credential stored anywhere.
iOS CI is manual, to save free Actions minutes. The iOS jobs need macOS runners, and on a
private repository GitHub counts each macOS minute 10 times against the free minutes. So pull
requests and pushes run only the Linux jobs of the Flutter SDK workflow (format, analyze, unit
tests, Android builds, publish dry run). The two iOS jobs (Flutter 3.27 with CocoaPods; Flutter
stable with Swift Package Manager and the XCTest suite, .github/workflows/ios.yml)
run only:
- when you start the workflow by hand: Actions → Flutter SDK → Run workflow, pick the
branch, keep Also run the iOS builds ticked. Do this before merging a change to the Swift
code,
ios/, the podspec orPackage.swift, and after a new Flutter stable; - on every
v*tag, inside the publish workflow, which publishes only when they pass.
One-time setup:
- Done: 0.1.0 was published by hand (
flutter pub publishin the repository root), because pub.dev automates only packages that already exist. - On pub.dev, open the package's Admin tab → Automated publishing → Enable publishing
from GitHub Actions, with repository
Tecbeckp/becklinks-flutterand tag patternv{{version}}. Turn on Require GitHub Actions environment with the environmentpub.dev. - On GitHub, create the environment
pub.dev(Settings → Environments) with required reviewers and a deployment tag rulev*, so only approved tags publish.
Each release:
-
Set the same version in
pubspec.yaml(version) and inlib/src/sdk_info.dart(sdkVersion), and give it a## <version> (<release date>)entry inCHANGELOG.md. -
Merge to
mainwith the Flutter SDK workflow green. If the release changes iOS code, run the workflow by hand onmainwith the iOS builds first, so a failure shows up before the tag. -
Tag that commit and push the tag:
git tag v0.1.0 git push origin v0.1.0
The workflow first checks that the tag, pubspec.yaml, sdk_info.dart and the CHANGELOG agree and
that the CHANGELOG entry no longer says "unreleased". It then runs both iOS builds on the tagged
commit; if one fails, nothing is published (re-run a flaky job, or fix the code and tag the fixed
commit after deleting the old tag).
After a reviewer approves the pub.dev environment it runs flutter pub publish --dry-run, which
fails on any warning, and then publishes.
License
MIT, see LICENSE.
Libraries
- becklinks_flutter
- Deep links, deferred deep links and attribution for Flutter apps with Beck Link.