docs
Documentation menu

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)

Heads up
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.
Note
No package to install — paste one file. Create 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).
yaml
# pubspec.yaml — the drop-in needs only Firebase (HTTP uses dart:io).
dependencies:
  firebase_core: ^3.0.0
  firebase_messaging: ^15.0.0

Paste this into lib/pushlane.dart:

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:

groovy
// android/app/build.gradle — apply the Google Services plugin
apply plugin: 'com.google.gms.google-services'
groovy
// android/build.gradle — project-level
dependencies {
    classpath 'com.google.gms:google-services:4.4.0'
}

iOS — Firebase + APNs:

  1. Add your APNs key in the Firebase console under Project → Cloud Messaging → iOS.
  2. 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.

dart
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());
}
Note
Everything is a no-op before 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.

dart
// 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);
  },
);
Heads up
If neither the file nor your hooks can be written, the drop-in refuses to track anonymous users and logs [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.

dart
// 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.

dart
// 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
});
Note
Re-call whenever a trait changes (e.g. after a subscription upgrade).

#3. Track events

Property values can be String, int, double, bool, or List. The backend coerces against your event catalogue — never coerce client-side.

dart
// 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');
Note
The drop-in emits three events on its own: 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.

Heads up

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.

dart
// 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.

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.

dart
// 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

Note

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.

CheckWhat it means
SDK detectedAt least one event has reached the ingest worker
Events instrumentedDistinct event names from your app appear in the catalogue
Device connectedAn 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.

APNs setup (.p8) →