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 — zero-dependency drop-in client for Flutter. No pub.dev 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).
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:firebase_messaging/firebase_messaging.dart';
class Pushlane {
static String? _tenantId;
static String? _key;
static String _apiBase = 'https://loop-ingest.loop-push.workers.dev';
static String _pushEnv = 'production';
static String? _userId;
static void configure({required String tenantId, required String publishableKey, String? apiBase, String pushEnvironment = 'production'}) {
_tenantId = tenantId;
_key = publishableKey;
if (apiBase != null && apiBase.isNotEmpty) _apiBase = apiBase;
_pushEnv = pushEnvironment;
}
static void identify(String userId) { _userId = userId; }
static void reset() { _userId = null; }
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) return;
} catch (e) { /* retry */ }
if (attempt < 4) {
final ms = min(pow(2, attempt).toInt() * 500, 30000);
await Future.delayed(Duration(milliseconds: ms));
}
}
}
static void track(String name, [Map<String, dynamic> properties = const {}]) {
if (_tenantId == null || _userId == null) return;
final tp = '00-' + _hex(32) + '-' + _hex(16) + '-01';
_post('/v1/events', {
'eventId': _uuid(), 'tenantId': _tenantId, 'externalId': _userId, 'name': name, 'properties': properties,
'occurredAt': DateTime.now().millisecondsSinceEpoch,
'context': {'sdk': 'pushlane-dropin-flutter', 'sdkVersion': '1.0.0'}, 'traceparent': tp,
}, extra: {'traceparent': tp});
}
static Future<void> registerForPush() async {
if (_tenantId == null || _userId == null) return;
await FirebaseMessaging.instance.requestPermission();
final token = await FirebaseMessaging.instance.getToken(); // FCM
if (token == null) return;
await _post('/v1/register', {'tenantId': _tenantId, 'externalId': _userId, 'deviceToken': token, 'pushEnvironment': _pushEnv});
}
static void setMarketingConsent(bool optedIn) {
if (_tenantId == null || _userId == null) return;
_post('/v1/consent', {'externalId': _userId, '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, identify the user, start, and register for push. Identify before any track call or registerForPush.
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.identify('user@example.com');
Pushlane.start(); // emits app_open
await Pushlane.registerForPush(); // requests permission, registers the FCM token
runApp(const MyApp());
}Pushlane.track and Pushlane.registerForPush are no-ops until you have called Pushlane.configure and Pushlane.identify. On logout:// On logout: detach this device from the user.
Pushlane.reset();#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 only (when you call Pushlane.start()). Unlike the native SDK it does not auto-track opened, 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).#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. Pushlane's sender delivers over APNs, so end-to-end push delivery is proven on iOS; Android FCM delivery is not yet end-to-end. 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.