Overview
retainza_flutter is the Dart client for Retainza. It identifies users, tracks events, keeps collections, registers push tokens and receives in-app messages from a Flutter app on iOS and Android.
- Dart 3.4+ / Flutter 3.22+
- iOS 15+ and Android 7.0 (API 24)+
Attribute rules, the offline queue, sessions and consent behave exactly as in the other client SDKs. The concepts section covers them at length; this page covers what is different in Flutter.
1. Install
Once the package is published, the dependency will be:
dependencies:
retainza_flutter: ^1.0.0then flutter pub get.
dependencies:
retainza_flutter:
path: ../sdk/flutter/retainza_flutterIt brings in http, shared_preferences, package_info_plus, device_info_plus and flutter_timezone. Firebase is deliberately not a dependency — an app using Retainza only for analytics should not inherit it. Add it yourself if you use push (Push).
The SDK is pure Dart with no platform channels of its own: nothing to link, and no pod install steps beyond what the plugins above need.
2. Start the SDK
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:retainza_flutter/retainza_flutter.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
Retainza.init(
publicKey: const String.fromEnvironment('RETAINZA_PK'), // pk_test_… or pk_live_…
debug: kDebugMode,
);
runApp(const MyApp());
}Then use Retainza.instance anywhere. Call init once; calling it again replaces the client. Retainza.instance throws a StateError if init has not run.
Only a public key (pk_…) is accepted — a secret key throws ArgumentError. The key decides the environment, read back as Retainza.instance.environment (RetainzaEnvironment.test for pk_test_, .live for pk_live_, .unknown for any other pk_ key). There is no runtime switch.
Platform facts (timezone, device, app version, install date) resolve asynchronously. Await them before the very first identify if it must carry them — every later call carries them regardless:
await Retainza.ready();Configuration worth knowing about
All of these are named parameters of Retainza.init:
| Parameter | Default | What it does |
|---|---|---|
| baseUrl | --dart-define=ENGAGE_BASE_URL, else https://api.retainza.com/v1 | Ingest API |
| userProvider | — | () => currentUserId; authoritative when non-null |
| debug | false | Logs requests and state. Off = no console output at all |
| logger | — | Receives lines instead of the console; gets warn/error even with debug off |
| defaultAttributes | — | Extra attributes on every identify. App values win |
| storage | SharedPreferencesStorage() | Queue, install id, session, base-URL override |
| autoSession | true | Sessions from the app lifecycle (Sessions) |
| requireConsent | false | Send nothing until analytics consent (Consent) |
| allowRuntimeBaseUrl | value of debug | Lets setBaseUrl work. Keep off in release |
| sessionTimeout | 30 min | Background time before a new session |
| maxQueuedEvents | 100 | Offline queue capacity |
| device | detected | Override detected DeviceInfo fields |
| httpClient | — | Override the HTTP client (tests, proxies) |
A dev screen can repoint a debug build without rebuilding:
setBaseUrl(url)— remembers an override; throwsStateErrorunlessallowRuntimeBaseUrl(ordebug) is on, and the URL must start withhttp://orhttps://.clearBaseUrl()— forgets the override.getBaseUrl()— the URL requests actually go to (stored override > config > dart-define > default).testConnection()— hits/health(one level above/v1) and returns(ok, status, error).
3. Identify & track
final r = Retainza.instance;
await r.identify(
'user_42',
attributes: {'plan': 'pro', 'trial_days_left': 3},
profile: const RetainzaProfile(firstName: 'Asha', email: 'asha@example.com', locale: 'en-IN'),
);
await r.setAttributes({'plan': 'team'}); // patch — sends only this
await r.track('checkout_completed', properties: {'amount': 499, 'currency': 'INR'});After identify, later calls use that user id; pass userId: to override, or configure userProvider. A call with no resolvable user throws StateError.
Attribute values are scalars: String, num, bool or null. null values in SDK/default layers are dropped rather than clearing server-side values.
RetainzaProfile fields become the reserved attributes first_name, last_name, email, phone, gender, birthdate; email and locale are also sent as top-level columns. Empty strings are ignored.
Collections
One-to-many data such as challenges, badges or saved searches. The item id is the merge key, so an offline replay never duplicates an item:
await r.addToCollection('challenges', CollectionItem('lagos_10k', {'status': 'joined'}));
await r.removeFromCollection('challenges', 'lagos_10k');
await r.setCollection('badges', [CollectionItem('early_bird'), CollectionItem('streak_7')]); // [] clearsA CollectionItem id may not be empty (the constructor throws ArgumentError).
Signed identity
Optional, and in the SDK's unreleased changes after 1.0.0. With identity verification on for an environment, your server signs each user id (hex(HMAC-SHA256(identitySecret, userId))) and returns it with the session. Pass it on every launch:
await Retainza.instance.identify(user.id, userHash: userHash);
// or, without re-identifying:
Retainza.instance.setUserHash(userHash);The hash is sent with every call for that user id and no other, is kept in memory only, and is cleared by reset(). Never put the identity secret in the app. See Identity.
Logout
await Retainza.reset();Unregisters this device's push token from the user being left, then clears the cached user id, the offline queue, session counters and in-app polling. The install id and install_date survive — they describe the device. If you use userProvider, make it return null after logout.
Attributes the SDK sets for you
platform, app_version, sdk_version, install_date, language, country, tz (IANA, e.g. Asia/Kolkata), device_model, device_manufacturer, os_name, os_version, the session counters (Sessions) and push_permission once you report it. country is the locale region, not GPS — no permission prompt is involved.
4. Push
Retainza sends push through Firebase Cloud Messaging on both platforms, so register the FCM token — on iOS too, not the raw APNs device token.
Set up Firebase for your app (flutterfire configure) and add firebase_core and firebase_messaging. On iOS, enable the Push Notifications capability and the Remote notifications background mode, and upload your APNs key in the Firebase console.
import 'package:firebase_messaging/firebase_messaging.dart';
Future<void> setUpPush() async {
final r = Retainza.instance;
final fm = FirebaseMessaging.instance;
// The SDK never prompts — asking is your product decision.
final settings = await fm.requestPermission();
await r.setPushPermission(switch (settings.authorizationStatus) {
AuthorizationStatus.authorized => PushPermission.authorized,
AuthorizationStatus.provisional => PushPermission.provisional,
AuthorizationStatus.denied => PushPermission.denied,
AuthorizationStatus.notDetermined => PushPermission.undetermined,
});
final token = await fm.getToken();
if (token != null) await r.registerPush(token, Retainza.pushPlatform);
fm.onTokenRefresh.listen((t) => r.registerPush(t, Retainza.pushPlatform));
}Call it after identify. registerPush is safe on every launch: an unchanged token is re-sent at most once a day (force: true overrides). It is never queued — a late token may already be stale — so it throws on network errors.
Opens and deep links
Every Retainza push carries engage_* keys in its data block. parseRetainzaPush returns null for pushes that belong to another SDK:
void handleOpen(RemoteMessage m) {
final push = parseRetainzaPush(m.data);
if (push == null) return; // not ours
Retainza.instance.ackPush(push.msgId, PushAction.opened).ignore();
if (push.route != null) Retainza.instance.resolveRoute(push.route!);
}
FirebaseMessaging.onMessageOpenedApp.listen(handleOpen);
final initial = await FirebaseMessaging.instance.getInitialMessage();
if (initial != null) handleOpen(initial);resolveRoute hands the route to the handler you register with onNavigate (In-app messages). Report PushAction.delivered from FirebaseMessaging.onMessage (foreground) if you want delivery counts — FCM has no delivery receipt of its own.
5. In-app messages
Poll for messages and hand each to your renderer:
final r = Retainza.instance;
r.onInApp((msg) {
// msg.template: banner | modal | bottomsheet | fullscreen | html
// msg.content: the full payload (title, body, buttons, media, style …)
showMyDialog(title: msg.content['title'] as String?, body: msg.content['body'] as String?);
});
final stop = r.startInAppPolling(); // every 15 s; returns a stop functionshown is acked automatically when a message is delivered to onInApp. Report the rest yourself:
await r.ackInApp(msg.msgId, InAppAction.clicked);
await r.ackInApp(msg.msgId, InAppAction.dismissed);fetchPendingInApp() does a single poll if you prefer to drive timing. The polling interval can be changed with startInAppPolling(interval: …).
Navigation
r.onNavigate((route) => navigatorKey.currentState?.pushNamed(route.screen, arguments: route.kv));Push opens and in-app buttons both resolve through this handler. A route is a screen name plus a kv map of string params.
Rendering it yourself (selfhandled)
Campaigns built with the self-handled template are never drawn by the SDK and never auto-acked. Your handler owns the acks — skipping them empties that campaign's funnel:
r.onSelfHandledInApp((h) {
showMyCard(h.message.content, onVisible: h.markShown, onTap: () => h.markClicked(), onClose: h.markDismissed);
});markClicked([route]) acks clicked and then navigates (to the given route, else the message's own). A self-handled message that arrives with no handler registered stays queued and is logged as a warning.
6. Offline queue
identify, setAttributes, track and the collection calls survive network failures. Network errors and 5xx responses are queued (100 items by default, oldest dropped first), persisted, and flushed in order with their original timestamps — backoff doubles from 1 s up to 5 min. A 4xx is thrown and never queued. Push registration and acks are never queued.
Retainza.instance.queueSize; // pending calls
await Retainza.instance.flushQueue(); // drain now7. Consent
Retainza.init(publicKey: pk, requireConsent: true);
// … later, from your consent UI:
await Retainza.instance.setConsent(analytics: true, marketing: false);With requireConsent, nothing leaves the device until analytics: true, and calls made before that are dropped, not queued. marketing never gates sending; it is stored as consent_marketing for targeting.
8. Sessions
With autoSession on (default), the SDK starts a session at init and on each return to the foreground, and banks foreground time when the app goes to the background. Returning within 30 minutes (sessionTimeout) resumes the same session. A new session emits a session_start event and updates session_count, first_session_at, last_session_at and session_duration_total.
Turn it off (autoSession: false) and call startSession() / endSession() yourself if you need custom rules.
9. Troubleshooting
| Symptom | Check |
|---|---|
| Nothing reaches the dashboard | debug: true and read the log; testConnection(); the key belongs to the environment you are looking at |
StateError: no userId | Call identify first, pass userId:, or set userProvider |
| Events arrive late | Device was offline — they were queued; queueSize shows the backlog |
Nothing sent at all with requireConsent | setConsent(analytics: true) has not been called |
| Push token registered, no pushes on iOS | You registered the APNs token instead of FirebaseMessaging.instance.getToken(), or no APNs key in Firebase |
| Self-handled campaign shows 0 impressions | Your handler never calls markShown() |
| First identify lacks device/timezone | await Retainza.ready() before it |
The SDK's example app exercises every call. With the SDK source checked out, run it from its example/ directory:
flutter run --dart-define=RETAINZA_PK=pk_test_xxxOther platforms: React Native, iOS, Android, server. The HTTP API is in the reference.
