Skip to content

Flutter SDK

The Flutter SDK (tolinku) provides event tracking, deferred deep linking, referrals, and in-app messages for Flutter 3.10+ and Dart 3.0+.

Add the package to your pubspec.yaml:

dependencies:
tolinku: ^0.5.0

Then run:

Terminal window
flutter pub get

The SDK depends on:

  • http for network requests
  • webview_flutter for rendering in-app messages
  • shared_preferences for dismiss/impression state persistence

Configure the SDK before runApp() or in your first widget’s initState:

import 'package:tolinku/tolinku.dart';
void main() {
Tolinku.configure(
apiKey: 'tolk_pub_your_key',
// baseUrl: 'https://your-app.tolinku.com', // optional
// debug: true, // optional
);
runApp(MyApp());
}

Access the singleton anywhere:

final tolinku = Tolinku.instance;
Tolinku.instance.setUserId('user_123');
// Clear on logout
Tolinku.instance.setUserId(null);
// Simple event
await Tolinku.instance.track('custom.app_open');
// Event with properties
await Tolinku.instance.track('custom.purchase', properties: {
'amount': '29.99',
'currency': 'USD',
});
// Force flush
await Tolinku.instance.flush();

Events are batched (10 events or 5-second timer). For lifecycle-aware flushing, implement WidgetsBindingObserver:

class MyApp extends StatefulWidget {
@override
_MyAppState createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused) {
Tolinku.instance.flush();
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
}

Track purchases, cart activity, and product events via ecommerce:

Tolinku.instance.setUserId('user_123');
// Track a purchase
await Tolinku.instance.ecommerce.purchase(
transactionId: 'order_456',
revenue: 49.99,
currency: 'USD',
items: [
TolinkuItem(itemId: 'sku_1', itemName: 'T-Shirt', price: 24.99, quantity: 2),
],
);
// Track product views and cart events
await Tolinku.instance.ecommerce.viewItem(
items: [TolinkuItem(itemId: 'sku_1', itemName: 'T-Shirt', price: 24.99)],
);
await Tolinku.instance.ecommerce.addToCart(
items: [TolinkuItem(itemId: 'sku_1', quantity: 1)],
);
await Tolinku.instance.ecommerce.beginCheckout();
// Search and ratings
await Tolinku.instance.ecommerce.search(searchTerm: 'shoes');
await Tolinku.instance.ecommerce.rate(itemId: 'sku_1', rating: 4.5, maxRating: 5);
// Force flush
await Tolinku.instance.ecommerce.flush();

Ecommerce events are batched (10 events or 5-second timer). The SDK manages cart IDs automatically via SharedPreferences, clearing them after purchase. For lifecycle-aware flushing, add Tolinku.instance.ecommerce.flush() to your WidgetsBindingObserver.didChangeAppLifecycleState handler alongside the regular flush.

A link arrives as the URL that was tapped, exactly as it was written. That is enough while the URL is readable, but every route also has a short link, and a short link is the same route written as a code:

https://links.example.com/s7k2p9q/4821

Nothing in that URL says which route it is, and nothing on the device can work it out. Short links are what the dashboard’s copy button gives you and what a QR code carries, so your app will receive them whether or not you chose to share them.

links.resolve asks Tolinku and answers with the route, the token and the canonical path. A readable URL resolves to itself, so resolve every incoming link rather than trying to spot the short ones. It returns nothing rather than throwing when it cannot reach us, so your own handling stays the fallback.

final uri = Uri.parse(link);
final resolved = await Tolinku.instance.links.resolve(link);
final path = resolved?.deepLinkPath ?? uri.path;
// path -> "/merchant/abc123", and resolved.token -> "abc123"

resolved.token saves you working out which segment the token is, which the URL alone does not tell you when a route’s prefix places its token mid-path.

Rebuilding the link on your own domain is Uri.replace, which carries the query string across untouched:

final target = uri.replace(host: 'www.example.com', path: path);
// https://www.example.com/merchant/abc123?utm_source=qr

Use Flutter’s uni_links or app_links package to listen for incoming URLs.

Tolinku.parseDeepLink takes a URL apart locally, with no network call:

final result = Tolinku.parseDeepLink('https://your-app.tolinku.com/merchant/abc123');
print(result.path); // "/merchant/abc123"
print(result.queryParams); // {}

It is enough when you already know the shape you are getting. It can only tell you what the URL says, so it cannot expand a short code.

Recover the link a user tapped before they had your app, and route them to it on first launch.

final link = await Tolinku.instance.deferred.claimDeferredLink(
appspaceId: '64f0a1b2c3d4e5f60718',
);
if (link != null) {
// Route to link.deepLinkPath
}

Call it once, on the first launch after install. One call covers both platforms: on Android the plugin reads the Play Install Referrer itself, and everywhere else it falls back to device signals.

  1. Play Install Referrer, on Android only. A Tolinku link sends the visitor to the store with a token attached and Play hands it back on first launch, naming the exact click rather than inferring it.
  2. Device signals, if there was no referrer, and always on iOS, which has no equivalent.

Both platforms compile a small native side. Android reads the Play Install Referrer; iOS reports the timezone, which Dart cannot express in the form matching compares against. The first iOS build after upgrading from 0.4.0 or earlier runs pod install.

A claim is consumed the first time it succeeds. claimDeferredLink remembers that it asked, so calling it again costs nothing.

Only a real answer is remembered. “Nothing waiting for this device” counts. A dropped request does not, so one bad connection does not spend the install’s only chance at attribution.

Both are supported, and neither is deprecated. They differ from claimDeferredLink in how much the SDK does for you, not in how well they match.

final byToken = await Tolinku.instance.deferred.claimByToken(token: token);
final bySignals = await Tolinku.instance.deferred.claimBySignals(
appspaceId: '64f0a1b2c3d4e5f60718',
);

claimBySignals collects the device signals itself, so it needs nothing beyond the Appspace ID. Pass any of timezone, language, screenWidth, screenHeight, devicePixelRatio or osVersion to use your own value instead of the collected one.

claimByToken is the name the other Tolinku SDKs use. This package originally shipped it as claim, which still works and behaves identically.

A link that opens your app directly never reaches Tolinku, so the tap is not counted. Those taps are the ones from people who already have your app, so leaving them out makes a campaign aimed at existing customers look like it got no traffic.

trackLinkOpen reports one. Call it wherever your app receives an incoming link.

A link arrives in two places and both need it. One that launches your app cold arrives somewhere different from one tapped while the app is already running, and instrumenting only the second misses the more common case while appearing to work.

// Launched by a link, app was not running.
final initial = await appLinks.getInitialAppLink();
if (initial != null) {
Tolinku.instance.trackLinkOpen(initial.toString());
}
// Tapped while the app was already open.
appLinks.uriLinkStream.listen((uri) {
Tolinku.instance.trackLinkOpen(uri.toString());
// your own routing
});

Wiring both is safe: some link plugins hand the launching link to the listener as well, and the same link inside a few seconds is reported once rather than counted twice.

Only http and https links are reported. A custom scheme means Tolinku’s own hand-off page opened your app, and that tap was counted when the page was served. The call never throws and never blocks.

Whether these are recorded is an Appspace setting, and it decides the bill. See Attributing app opens.

final referrals = Tolinku.instance.referrals;
// Create a referral code
final result = await referrals.create(userId: 'user_123', userName: 'Jane');
print(result.referralCode); // "ABC123"
print(result.referralUrl); // "https://myapp.tolinku.com/ref/ABC123"
// Look up a referral
final info = await referrals.get('ABC123');
// Link a referred user (status stays pending until reward milestone is reached)
await referrals.complete(
code: 'ABC123',
referredUserId: 'user_456',
);
// Update milestone (completes the referral if it matches the reward milestone)
await referrals.milestone(code: 'ABC123', milestone: 'first_purchase');
// Claim reward (after granting it in your system)
await referrals.claimReward(code: 'ABC123');
// Get leaderboard
final leaders = await referrals.leaderboard(limit: 10);

Display messages using the built-in WebView dialog:

await Tolinku.instance.messages.show(
context,
trigger: 'on_open',
onAction: (action) {
// Handle CTA action URL
},
onDismiss: () {
// Message dismissed
},
);

The SDK fetches active messages, filters out dismissed ones, and presents the highest-priority message in a full-screen WebView dialog.

await Tolinku.instance.destroy();

dispose() is the same call and still works. It is the ordinary Dart name for this, so unlike the aliases on the other SDKs it is not going anywhere. destroy() is the name every Tolinku SDK uses, so an app sharing code across platforms can call one name everywhere.

This flushes queued events, closes the HTTP client, and clears the singleton.