Flutter Quickstart
Dart SDK with firebase_messaging — background handler before runApp.
#Prerequisites
- Flutter SDK with Dart 3 or later
- A Firebase project (
firebase_core+firebase_messaging) - For iOS: a physical device running iOS 14+ and a paid Apple Developer account
- Your Pushlane Tenant ID, Ingest URL, and write-key — copy them from Settings → Install SDK
#1. Add the Pushlane client (no pub.dev package)
pushlane_flutter is not on pub.dev. Don't add pushlane_flutter: ^0.1.0 to pubspec.yaml. Use the zero-dependency drop-in below (recommended), or build the SDK from source in the Pushlane monorepo.lib/pushlane.dart and paste the client below. HTTP uses dart:io (no http package); push registration uses firebase_messaging, which your app already has for push. Your AI agent can scaffold this exact file via the Pushlane MCP (get_install_instructions).# pubspec.yaml — the drop-in needs only Firebase (HTTP uses dart:io).
dependencies:
firebase_core: ^3.0.0
firebase_messaging: ^15.0.0Paste this into lib/pushlane.dart:
// Pushlane — drop-in client for Flutter. No Pushlane package to install.
// Paste this into lib/pushlane.dart. HTTP uses dart:io (no http package needed); push
// registration uses firebase_messaging (already present if your app does push).
//
// NOTIFICATION OPENS: getInitialMessage + onMessageOpenedApp are wired from configure().
// Honest limit: Pushlane's sender is APNs-only today, so a Flutter app does not receive
// Pushlane pushes yet — this is wired forward. For an iOS tap your app handles itself,
// call Pushlane.handleNotificationOpen(payload).
//
// ANONYMOUS USERS: identify() is OPTIONAL. If your app has no sign-up (or the user
// has not signed in yet), Pushlane mints ONE stable anonymous id per install and
// persists it, so events AND the push token still reach the server. It persists to a
// private file in the app sandbox by default; pass readAnonymousId/writeAnonymousId
// to configure() to use your own store (e.g. shared_preferences) instead.
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:firebase_messaging/firebase_messaging.dart';
class Pushlane {
static const String _anonKey = 'com.loop.sdk.anonymousId';
static String? _tenantId;
static String? _key;
static String _apiBase = 'https://loop-ingest.loop-push.workers.dev';
static String _pushEnv = 'production';
static String? _explicitId;
static Future<String?>? _anonReady;
static String? _lastToken;
static String? _lastPlatform;
static bool _warnedNoStore = false;
static Future<String?> Function()? _readAnon;
static Future<void> Function(String)? _writeAnon;
static void configure({
required String tenantId,
required String publishableKey,
String? apiBase,
String pushEnvironment = 'production',
Future<String?> Function()? readAnonymousId,
Future<void> Function(String)? writeAnonymousId,
}) {
_tenantId = tenantId;
_key = publishableKey;
if (apiBase != null && apiBase.isNotEmpty) _apiBase = apiBase;
_pushEnv = pushEnvironment;
_readAnon = readAnonymousId;
_writeAnon = writeAnonymousId;
_anonReady = null;
_installOpenTracking();
}
// -- Notification opens ------------------------------------------------------
// Pushlane stamps message_id / flow_id / node_id into every push it sends.
// Echoing message_id back on an 'opened' event is the ONLY thing that ties an
// open to the exact notification that caused it (open rate = attributed opens /
// sends). An open with no message_id, or under any other event name, is invisible
// to the metric. Wired for you below -- do NOT add a second listener, or you will
// log two events per tap.
//
// HONEST LIMIT: Pushlane's sender is APNs-only today, so a Flutter app only
// receives Pushlane pushes on iOS -- and firebase_messaging surfaces the iOS tap
// only for Firebase-marked notifications, not raw APNs. That means nothing arrives
// here yet in practice. The listeners below are correct and will start producing
// opens the day FCM delivery ships; until then, forward taps yourself from your
// existing iOS notification handler with Pushlane.handleNotificationOpen(payload).
static const List<String> _interactionKeys = ['message_id', 'flow_id', 'node_id'];
static bool _openTrackingReady = false;
static final Set<String> _seenOpens = <String>{};
static Map<String, dynamic> _interactionProps(Map<dynamic, dynamic>? data) {
final props = <String, dynamic>{};
if (data == null) return props;
for (final k in _interactionKeys) {
final v = data[k];
if (v is String && v.isNotEmpty) props[k] = v;
}
return props;
}
/// One tap -> exactly one event (getInitialMessage and the stream can both
/// deliver the SAME launch message).
static void _trackOpen(RemoteMessage? message) {
if (message == null) return;
final props = _interactionProps(message.data);
final key = message.messageId ?? (props['message_id'] as String?) ?? '';
if (key.isNotEmpty) {
if (_seenOpens.contains(key)) return;
_seenOpens.add(key);
}
track('opened', props);
}
static void _installOpenTracking() {
if (_openTrackingReady) return;
_openTrackingReady = true;
try {
// Cold start: the app was launched BY tapping a notification.
FirebaseMessaging.instance.getInitialMessage().then(_trackOpen).catchError((e) {});
// Warm tap: the app was in the background.
FirebaseMessaging.onMessageOpenedApp.listen(_trackOpen);
} catch (e) {
// never throw into the host app
}
}
/// Report an open Pushlane could not see itself -- e.g. an iOS tap delivered to
/// your own notification handler. Pass the push payload (the map holding
/// message_id). The same notification is only ever counted once.
static void handleNotificationOpen(Map<dynamic, dynamic>? payload) {
final props = _interactionProps(payload);
final key = (props['message_id'] as String?) ?? '';
if (key.isNotEmpty) {
if (_seenOpens.contains(key)) return;
_seenOpens.add(key);
}
track('opened', props);
}
/// OPTIONAL — anonymous users work without it. Call it when a real user id exists.
static void identify(String userId) {
if (userId.isEmpty || _explicitId == userId) return;
_explicitId = userId;
_rebindDevice(); // the device follows the user
}
/// Logout: forget the account AND mint a fresh anonymous identity for this device.
static void reset() {
_explicitId = null;
_anonReady = _loadAnonId(mintFresh: true);
_rebindDevice();
}
// The app-private directory the anonymous id file lives in. Derived from
// Directory.systemTemp (iOS: the app sandbox tmp; Android: the app cache dir),
// so no path_provider dependency is needed.
static Future<Directory?> _durableDir() async {
try {
final root = Directory.systemTemp.parent;
if (!await root.exists()) return null;
for (final name in ['files', 'Documents', 'Library']) {
final d = Directory(root.path + Platform.pathSeparator + name);
if (await d.exists()) return d;
}
return root;
} catch (e) {
return null;
}
}
static void _warnNoStore() {
if (_warnedNoStore) return;
_warnedNoStore = true;
// NEVER silent: without a store an anonymous id would be re-minted on every cold
// start, which would fragment flows and inflate your billable user count. So we
// stay inert for anonymous users and say exactly how to fix it.
print('[pushlane] no persistent storage available: anonymous users are NOT tracked. Pass readAnonymousId/writeAnonymousId to Pushlane.configure() (e.g. shared_preferences). Users you identify() explicitly are unaffected.');
}
static Future<String?> _loadAnonId({bool mintFresh = false}) async {
final read = _readAnon;
final write = _writeAnon;
try {
if (read != null && write != null) {
final existing = mintFresh ? null : await read();
if (existing != null && existing.isNotEmpty) return existing;
final minted = 'anon_' + _uuid();
await write(minted);
return minted;
}
final dir = await _durableDir();
if (dir == null) { _warnNoStore(); return null; }
final file = File(dir.path + Platform.pathSeparator + '.' + _anonKey);
if (!mintFresh && await file.exists()) {
final existing = (await file.readAsString()).trim();
if (existing.isNotEmpty) return existing;
}
final minted = 'anon_' + _uuid();
await file.writeAsString(minted, flush: true);
return minted;
} catch (e) {
_warnNoStore();
return null;
}
}
/// The id every call uses: an explicit identify() wins, else the anonymous id.
static Future<String?> _currentUserId() async {
if (_explicitId != null) return _explicitId;
if (_tenantId == null) return null;
_anonReady ??= _loadAnonId();
final anon = await _anonReady;
return _explicitId ?? anon;
}
// Re-post the known device token under the CURRENT id (login / logout). The server
// re-attributes a device token to the identity that registered it last.
static void _rebindDevice() {
final token = _lastToken;
final platform = _lastPlatform;
if (token == null || platform == null) return;
() async {
final uid = await _currentUserId();
if (uid == null || _tenantId == null) return;
await _post('/v1/register', {'tenantId': _tenantId, 'externalId': uid, 'deviceToken': token, 'pushEnvironment': _pushEnv, 'platform': platform});
}();
}
/// Persistent user attributes — feeds {{ name | fallback }} personalisation at send time.
static void setAttributes(Map<String, dynamic> attributes) {
if (_tenantId == null) return;
() async {
final uid = await _currentUserId();
if (uid == null) return;
await _post('/v1/attributes', {'tenantId': _tenantId, 'externalId': uid, 'attributes': attributes});
}();
}
static String _uuid() {
final r = Random();
final b = List<int>.generate(16, (_) => r.nextInt(256));
b[6] = (b[6] & 0x0f) | 0x40;
b[8] = (b[8] & 0x3f) | 0x80;
final h = b.map((x) => x.toRadixString(16).padLeft(2, '0')).join();
return h.substring(0, 8) + '-' + h.substring(8, 12) + '-' + h.substring(12, 16) + '-' + h.substring(16, 20) + '-' + h.substring(20);
}
static String _hex(int n) {
final r = Random();
final sb = StringBuffer();
for (var i = 0; i < n; i++) sb.write(r.nextInt(16).toRadixString(16));
return sb.toString();
}
static Future<void> _post(String path, Map<String, dynamic> body, {Map<String, String>? extra}) async {
if (_tenantId == null) return;
var base = _apiBase;
while (base.length > 1 && base.endsWith('/')) base = base.substring(0, base.length - 1);
for (var attempt = 0; attempt <= 4; attempt++) {
try {
final client = HttpClient();
final req = await client.postUrl(Uri.parse(base + path));
req.headers.set('Content-Type', 'application/json');
if (_key != null && _key!.isNotEmpty) req.headers.set('Authorization', 'Bearer ' + _key!);
if (extra != null) extra.forEach((k, v) => req.headers.set(k, v));
req.add(utf8.encode(jsonEncode(body)));
final res = await req.close();
client.close();
if (res.statusCode >= 200 && res.statusCode < 300) return;
if (res.statusCode < 500 && res.statusCode != 429) {
// non-transient: drop, but SAY SO (a silent 4xx once hid a register
// rejection for weeks). Never throws into the host app.
print('[pushlane] ' + path + ' rejected with HTTP ' + res.statusCode.toString() + ' (dropped)');
return;
}
} catch (e) { /* retry */ }
if (attempt < 4) {
final ms = min(pow(2, attempt).toInt() * 500, 30000);
await Future.delayed(Duration(milliseconds: ms));
}
}
}
// Best-effort device locale for the event context (sent raw; the server normalises).
static String? _localeName() {
try {
return Platform.localeName;
} catch (e) {
return null;
}
}
static void track(String name, [Map<String, dynamic> properties = const {}]) {
if (_tenantId == null) return;
final occurredAt = DateTime.now().millisecondsSinceEpoch; // stamped NOW
() async {
final uid = await _currentUserId();
if (uid == null) return;
final tp = '00-' + _hex(32) + '-' + _hex(16) + '-01';
final context = <String, dynamic>{'sdk': 'pushlane-dropin-flutter', 'sdkVersion': '1.3.0'};
final loc = _localeName();
if (loc != null && loc.isNotEmpty) context['locale'] = loc; // server normalises + drives language
await _post('/v1/events', {
'eventId': _uuid(), 'tenantId': _tenantId, 'externalId': uid, 'name': name, 'properties': properties,
'occurredAt': occurredAt,
'context': context, 'traceparent': tp,
}, extra: {'traceparent': tp});
}();
}
static Future<void> registerForPush() async {
if (_tenantId == null) return;
final uid = await _currentUserId();
// No id => no durable store (see _warnNoStore) => nothing to attach a device to.
if (uid == null) return;
await FirebaseMessaging.instance.requestPermission();
final token = await FirebaseMessaging.instance.getToken(); // FCM
if (token == null) {
// SAY SO. A silent return here is indistinguishable from "the app never called
// registerForPush() on this path" -- and that ambiguity is exactly what makes a
// low reachable-user count impossible to diagnose. Usually a denied OS prompt.
track('push_registration_failed', {'reason': 'token_unavailable'});
return;
}
// platform 'fcm': registered honestly — Android/FCM delivery is not live
// yet (Pushlane's sender is APNs-only for now).
_lastToken = token;
_lastPlatform = 'fcm';
await _post('/v1/register', {'tenantId': _tenantId, 'externalId': uid, 'deviceToken': token, 'pushEnvironment': _pushEnv, 'platform': 'fcm'});
}
static void setMarketingConsent(bool optedIn) {
if (_tenantId == null) return;
() async {
final uid = await _currentUserId();
if (uid == null) return;
await _post('/v1/consent', {'externalId': uid, 'category': 'marketing', 'action': optedIn ? 'opt_in' : 'opt_out'});
}();
}
static void start() { track('app_open'); }
}
Android — Firebase setup: Add google-services.json to android/app/, then apply the plugin in your app-level and project-level Gradle:
// android/app/build.gradle — apply the Google Services plugin
apply plugin: 'com.google.gms.google-services'// android/build.gradle — project-level
dependencies {
classpath 'com.google.gms:google-services:4.4.0'
}iOS — Firebase + APNs:
- Add your APNs key in the Firebase console under Project → Cloud Messaging → iOS.
- In Xcode, enable Push Notifications under Signing & Capabilities.
#2. Configure the client
Initialize Firebase, then configure Pushlane, start, and register for push. That is the whole integration — nothing here requires a logged-in user.
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'pushlane.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
Pushlane.configure(
tenantId: 'your-tenant-id',
publishableKey: 'lpk_live_…', // from Settings → Install SDK
);
Pushlane.start(); // emits app_open
await Pushlane.registerForPush(); // requests permission, registers the FCM token
// works right now — no account needed
runApp(const MyApp());
}Pushlane.configure, and only before it. track, registerForPush and setAttributes all work from the first launch.#Anonymous users (apps without sign-up)
Pushlane.identify is optional. If you never call it, the drop-in mints one stable anonymous id per install (anon_…) and persists it, so a user who never creates an account still gets a device token, still emits events, and still enters flows. Flutter needs no extra package for this — the id goes to an app-private file inside your sandbox.
// By default the drop-in writes the anonymous id to an app-private file — no
// extra package needed. If you already use shared_preferences, hand it those
// two hooks instead and the id lives with the rest of your app state:
final prefs = await SharedPreferences.getInstance();
Pushlane.configure(
tenantId: 'your-tenant-id',
publishableKey: 'lpk_live_…',
readAnonymousId: () async => prefs.getString('com.loop.sdk.anonymousId'),
writeAnonymousId: (id) async {
await prefs.setString('com.loop.sdk.anonymousId', id);
},
);[pushlane] no persistent storage found. It will not invent a new id on every launch, because that would fragment every flow and inflate your billable user count. Users you identify() explicitly are unaffected.#Identify signed-in users
If your app does have accounts, call Pushlane.identify when the session resolves. It takes over from the anonymous id and re-registers the device token under the real one. Calling it twice with the same id does nothing. Pushlane.reset() on logout mints a fresh anonymous identity for the device.
// ONLY if your app has accounts. Call it when auth resolves — the device
// token you already registered moves onto this id automatically.
Pushlane.identify('user@example.com');
// If you also use RevenueCat, pass the SAME id to Purchases.logIn(). That id
// is what links a purchase to this user.
// On logout: forget the account and start a fresh anonymous identity.
Pushlane.reset();#Set user attributes
Call Pushlane.setAttributes to persist user traits that power {{ first_name | friend }} personalisation tokens in your notification copy. It works for anonymous users too.
// Persist traits used in notification personalisation.
// Powers {{ first_name | friend }} tokens in your notification copy.
Pushlane.setAttributes({
'first_name': user.firstName, // String
'plan': 'growth', // String
'trial_days_left': 7, // int
});#3. Track events
Property values can be String, int, double, bool, or List. The backend coerces against your event catalogue — never coerce client-side.
// Values: String, int, double, bool, List.
Pushlane.track('plan_viewed', {'plan': 'pro', 'trial': true});
Pushlane.track('purchase_completed', {'amount': 9.99, 'currency': 'USD'});
// No-properties variant:
Pushlane.track('app_rated');app_open (when you call Pushlane.start()), opened on a notification tap (wired for you — read the limits below), and push_registration_failed with a reason property when registerForPush cannot obtain an FCM token (token_unavailable, usually a denied OS prompt) — a failure is logged, never swallowed. It does not auto-track received or session_started — instrument the events you care about explicitly with Pushlane.track. Rename any reserved name such as message_sent (see Events & catalogue).#Notification opens — wired, but nothing to open yet
Pushlane.configure installs both tap handlers for you: getInitialMessage() for a cold start (the app was launched by the notification) and onMessageOpenedApp for a warm tap. Each tap emits one opened event carrying the message_id Pushlane stamped on the push — that id is the only thing that attributes an open to the send it came from. The same tap arriving through both paths is counted once.
Honest limit — a Flutter app has nothing to open yet. Pushlane's sender is APNs-only and this drop-in registers an FCM token, so sends to those users are suppressed with fcm_delivery_unsupported (see Push delivery below) — no Pushlane push reaches the device. And even for an iOS push that did arrive, firebase_messaging surfaces a tap only for Firebase-marked notifications, not raw APNs. The handlers are correct and wired forward: they start producing opened events the day FCM delivery ships, with no code change on your side.
Meanwhile, if a Pushlane push does reach your own iOS notification handler, forward it with Pushlane.handleNotificationOpen(payload). There is no trackOpen — this is the one manual entry point. For proven end-to-end iOS opens today, use the native iOS SDK.
// Opens are wired by Pushlane.configure() — do NOT add your own
// getInitialMessage/onMessageOpenedApp handler for Pushlane pushes, or
// every tap logs two 'opened' events.
// Escape hatch: if a Pushlane push reaches your OWN notification handler
// (raw-APNs iOS taps never come through firebase_messaging), forward the
// payload map that holds message_id. The same tap counts once.
Pushlane.handleNotificationOpen(payload);received is not emitted by any drop-in: counting a delivery the user never opened requires a native iOS Notification Service Extension, which is not something you can paste into one Dart file.
#4. Consent (opt-out model)
Pushlane is opt-out by default — registration alone is sufficient for delivery. Call setMarketingConsent only when the user makes an explicit choice in your settings screen.
// Pushlane is opt-out by default — call this only when the user makes an
// explicit choice in your settings screen.
// User turned the marketing toggle OFF:
Pushlane.setMarketingConsent(false);
// User turned it back ON:
Pushlane.setMarketingConsent(true);#Push delivery — what works today
Pushlane.track works over HTTP — your events and audiences flow into Pushlane immediately.
registerForPush registers your FCM token (platform: "fcm" — the device appears in your Audience). Pushlane's sender delivers over APNs (proven end-to-end with the native iOS SDK); FCM delivery is not live yet, so sends to FCM-only users are suppressed with the logged reason fcm_delivery_unsupported, never silently dropped. Use the drop-in to get your events and audiences into Pushlane today.
#5. Verify the integration
Run your app on a physical device. In the Pushlane dashboard, open Settings → Install SDK and watch the live verification panel.
| Check | What it means |
|---|---|
| SDK detected | At least one event has reached the ingest worker |
| Events instrumented | Distinct event names from your app appear in the catalogue |
| Device connected | An FCM token was registered |
#Next step
To send real pushes to iOS devices you still need to upload your Apple push key (.p8) to Pushlane.