Flutter SDK
Last updated
The whisperr package sends identify calls, product events and push tokens
from a Flutter app to Whisperr. Use this page to install it, identify users,
track events and wire push notifications.
Using a coding agent? Connect it to the Whisperr MCP server. It installs the SDK and wires your events and push for you.
Install
Section titled “Install”dependencies: whisperr: ^0.8.0Or run flutter pub add whisperr to add the latest version. The package is
whisperr on pub.dev. It needs Flutter
3.19 or later and Dart 3.4 or later. Release notes are in the
changelog.
Initialize
Section titled “Initialize”Call Whisperr.initialize once at startup, for example in main. Create an
App key (wpk_…, the publishable key) in the Whisperr dashboard under
Settings → API keys.
import 'package:whisperr/whisperr.dart';
await Whisperr.initialize(apiKey: 'wpk_your_publishable_key');baseUrl defaults to https://api.whisperr.net. Pass it only to target a
local backend.
Identify
Section titled “Identify”Call identify after login and on every app start when a user is already
signed in (session restore). This also covers users who signed in before you
added the SDK: if your app has a stored user id at start, call identify
with it. Calling it again with the same values is safe. Whisperr merges
traits on the server.
Send only the data that messages need. Use first_name for a greeting. Do not
send a last name, full name, email address or phone number as a trait. Send
the email address as a channel:
await Whisperr.instance.identify( user.id, traits: {'first_name': user.firstName, 'plan': user.plan}, channels: [ WhisperrChannel.email(user.email, optedIn: user.emailOptedOut ? false : null), ],);The Flutter SDK uses the publishable key, so Whisperr ignores verified from
it. Pass the user’s choice as optedIn when the app knows it.
What Whisperr does with the address depends on your answer to Does your
signup get permission to email users? With Yes, the address is opted
in unless the app sends optedIn: false. With Per user, only when the app
sends optedIn: true. With No, or when Require a server key is on,
Whisperr stores it as pending until your backend confirms it with a
server SDK. optedIn: false
always records an opt-out. An earlier opt-out, including the unsubscribe link and bounce or complaint
suppression, is kept. A later identify from the app never opts the address
back in.
App-side consent needs the Flutter whisperr package 0.7.0 or later. Older versions cannot set it: Whisperr stores the address as
pending, whatever your answer. Older versions also send
opted_in: true on the email shortcut by default. See Contact consent for the limits on
addresses from the app.
Send push tokens with setPushToken, not as a channel.
Change a user’s existing email address or phone number only from your backend
with the server key. Do not send a changed address from the app. Whisperr
answers 403 (contact_change_requires_server_key) to that identify. The
Flutter SDK treats every 403 like a rejected key: it reports an auth
error, pauses delivery and keeps the identify at the head of the queue, so
later events wait behind it. A 403 from identify can mean this, not only a
bad key.
identify() also sends the device locale and timezone_offset_minutes as
traits. Whisperr uses them for message language and quiet hours. Pass your own
traits['timezone'] (an IANA name such as Europe/Berlin) or
traits['locale'] to override them.
Restore identify at app start
Section titled “Restore identify at app start”At each start with a stored user, the Flutter SDK sends one identify with the
user id, the anonymous_id, the device traits and the app language. It waits
3 seconds (WhisperrOptions.restoreIdentifyDelay), and your own identify()
in that time replaces it. It is skipped while opted out, and when the same
values went out less than 24 hours ago. The SDK cannot know a user who signed
in before you added the SDK, so still call identify() on session restore.
App language
Section titled “App language”await Whisperr.instance.setAppLanguage('pt-BR'); // or Locale.toString(): 'pt_BR'Set the language the user picked in your app (not the device locale), as a
BCP 47 tag. Whisperr writes messages in this language. The SDK stores the tag
and sends it at once as the trait app_language, also before login. Every
identify() carries it. A traits['app_language'] you pass wins. null
clears the stored value. An invalid tag is ignored. reset() keeps it.
Record product events. The SDK buffers them and sends them in batches. The timestamp is taken at call time, so events recorded offline keep their real time.
// An example name. Use your app's own action.Whisperr.instance.track('order_placed', properties: {'item_count': 3});Event names must be snake_case. The SDK drops other names before they enter
the queue and reports them to onError. See Event design.
Only registered events drive interventions. See
What to track.
Do not send personal data, such as an email address, phone number or name, in event properties. Whisperr drops property keys named like personal data. See Track events.
What happens before login?
Section titled “What happens before login?”track() works before identify(). The SDK sends the event under a saved
random anonymous_id. The next identify() carries the same id, so Whisperr
moves those events to the user. reset() starts a new anonymous id.
Screens
Section titled “Screens”From version 0.8.0, add WhisperrNavigatorObserver to your navigator. It
sends screen_viewed for each page route, named by its RouteSettings.name
(unnamed routes, dialogs and popups are skipped; pass nameOf to name them
yourself):
MaterialApp(navigatorObservers: [WhisperrNavigatorObserver()]);With another router, call screen() yourself:
Whisperr.instance.screen('Checkout'); // sends screen_viewed with screen_nameAutomatic events
Section titled “Automatic events”The Flutter SDK sends these events. You write no code.
| Event | When | Own properties |
|---|---|---|
app_installed | first launch | app_version, app_build |
app_updated | first launch of a new version or build | app_version, app_build, previous_version, previous_build |
app_opened | launch, and each return from background | cold_start, launch_source, and the launch details |
app_backgrounded | the app leaves the screen | foreground_ms, session_id, and the counts for that visit: screen_count, unique_screen_count, event_count (your track() events), error_count |
app_error | an uncaught Dart or Flutter error, or trackError() | error_kind, fatal, error_name (the type name, never the message), fingerprint, screen_name, session_id |
app_crashed | next launch after the app ended while in the foreground | crash_kind, detected_by, crashed_app_version, crashed_session_id, crashed_screen_name, foreground |
Each SDK event, also screen_viewed, push_opened and push_received,
carries sdk_name (whisperr-flutter), sdk_version, app_version,
app_build, platform, os_name, os_version, locale and
timezone_offset_minutes. A value the platform cannot provide is left out,
for example os_version on Android. Every event, also your own, carries
app_version, app_build, platform, sdk_name and sdk_version in its
context.
Turn the automatic events off with
WhisperrOptions(trackAutomaticEvents: false). Install and update detection
need persistence, which is on by default.
From version 0.8.0, the SDK also sends sessions, the launch source, and errors and crashes, as described below.
Sessions
Section titled “Sessions”Every event carries context.session_id (sess_ plus a UUID). A new
session starts on each cold start and after 30 minutes without an event.
app_backgrounded repeats the id and counts that foreground visit.
foreground_ms uses a monotonic clock.
Launch source
Section titled “Launch source”app_opened waits up to 1500 ms (launchSourceWindow) for what opened the
app, and sends launch_source: push, deep_link, universal_link,
widget, shortcut, email or direct. A push tap reported with
trackPushOpened() makes it push. Pass links to handleLaunchUri(), for example from
app_links:
final links = AppLinks();final initial = await links.getInitialLink();if (initial != null) Whisperr.instance.handleLaunchUri(initial);links.uriLinkStream.listen(Whisperr.instance.handleLaunchUri);Only the path is sent, as link_route, with ids replaced by :id. The query
and the fragment are never sent; utm_source, utm_medium and
utm_campaign are copied when present, and link_host for an http(s) link.
wsrc=widget or wsrc=shortcut on the link marks a widget or shortcut
launch, and wmid marks a Whisperr email. first_after_update: true marks
the first open after an update. flush() and going to the background end the
wait.
Errors and crashes
Section titled “Errors and crashes”The SDK chains into FlutterError.onError and
PlatformDispatcher.instance.onError. Handlers you set before
Whisperr.initialize still run. Each uncaught error is sent as app_error
with its type name and a fingerprint of its top 3 stack frames, never the
message or the stack. One per fingerprint per session, at most 20 per session.
Turn automatic capture off with WhisperrOptions(captureErrors: false).
Report a handled error yourself:
try { await loadCart();} catch (e, st) { Whisperr.instance.trackError(e, stackTrace: st, screen: 'Cart');}// A name of your own, and a kind (handled, network, screen):Whisperr.instance.trackError(response, name: 'HTTP_500', kind: 'network');When the app ends while it is in the foreground (it never went to the
background), the next launch sends app_crashed before its app_opened.
Debug builds never send it. Flutter keeps running after an uncaught Dart
error, so these errors are not marked fatal. These events are diagnostics
data for your privacy answers.
Push notifications
Section titled “Push notifications”Whisperr sends push through your own provider account. Connect it in the
dashboard first: see Connect a push provider.
With firebase_messaging, connect Firebase Cloud Messaging.
How do I set up push with firebase_messaging?
Section titled “How do I set up push with firebase_messaging?”whisperr_firebase_messaging
connects firebase_messaging to Whisperr. It asks for permission, reports
it, registers the FCM token with its kind and Firebase project id, follows
token refreshes and permission changes, reports foreground pushes, and tracks
notification taps with deep links.
dependencies: whisperr: ^0.6.0 whisperr_firebase_messaging: ^0.1.0 firebase_messaging: ^16.0.0Set up Firebase for your app first (flutterfire configure). On iOS, enable
the Push Notifications capability and upload your APNs key to Firebase.
import 'package:firebase_core/firebase_core.dart';import 'package:firebase_messaging/firebase_messaging.dart';import 'package:whisperr/whisperr.dart';import 'package:whisperr_firebase_messaging/whisperr_firebase_messaging.dart';
await Firebase.initializeApp();await Whisperr.initialize(apiKey: 'wpk_your_publishable_key');
final messaging = FirebaseMessaging.instance;
// After login, at the moment you want the OS prompt:await Whisperr.instance.registerFirebaseMessaging(messaging);
// Taps, including the one that launched the app:await Whisperr.instance.handleNotificationOpens( messaging, onOpen: (open, message) { if (open.deepLink != null) router.go(open.deepLink!); },);registerFirebaseMessaging never throws, and calling it again is safe. Its
options:
| Option | Default | What it does |
|---|---|---|
requestPermission | true | Show the OS prompt. false only reads the permission. |
provisional | false | iOS quiet delivery without a prompt. |
useApnsToken | false | On iOS, register the raw APNs token (kind: apns) instead of the FCM token. Use it only when Whisperr sends through APNs directly. |
apnsEnvironment | none | With useApnsToken: sandbox for development-signed builds, production for TestFlight and the App Store. The package cannot read it, so pass it. |
watchPermission | true | Check the permission again on each resume. |
trackForegroundReceipts | true | Report foreground Whisperr pushes as push_received. |
It needs firebase_messaging 15.1 to 16.x and whisperr 0.6.0 or later. The
rest of this section is for apps that wire push by hand.
Token and kind
Section titled “Token and kind”final messaging = FirebaseMessaging.instance;
// Current token (safe on every launch; repeats send nothing):final token = await messaging.getToken();if (token != null) { await Whisperr.instance.setPushToken(token, kind: WhisperrPushTokenKind.fcm);}
// Rotations, forwarded automatically:final sub = Whisperr.instance.attachPushTokenStream( messaging.onTokenRefresh, kind: WhisperrPushTokenKind.fcm,);The kind tells Whisperr which provider can send to the token:
| Token | Call |
|---|---|
firebase_messaging token (Android and iOS) | setPushToken(token, kind: WhisperrPushTokenKind.fcm) |
| Raw APNs token (iOS, sending through APNs directly) | setPushToken(token, kind: WhisperrPushTokenKind.apns, pushEnv: WhisperrPushEnvironment.production) |
| OneSignal subscription id | setPushToken(id, kind: WhisperrPushTokenKind.oneSignalSubscription) |
pushEnvis the APNs environment:sandboxfor development-signed builds,productionfor TestFlight and the App Store. The SDK never guesses it. It goes only with APNs tokens. Send APNs tokens only when you connect Apple Push Notification service in Whisperr.providerProjectis the Firebase project id for an FCM token.whisperr_firebase_messagingsends it for you.- Every token also carries the
platformand the bundle id or package name (client_app_id, frompackage_info_plus). - Without
kind, Whisperr infers the kind from the token’s format.
Token lifecycle
Section titled “Token lifecycle”- Called after login,
setPushTokensends the token at once. - Called before login, the token goes out at once under the device
anonymous_id, so the device can get push before sign-in. The nextidentify()moves it to the user. - Repeats send nothing, also after a restart. Every 7 days the SDK sends the same token once more, so Whisperr knows it is still live.
- Token rotation: the SDK opts the previous token out and the new one in. Tokens on the user’s other devices are not touched.
reset()(logout) opts the token out for the old user and registers it under the newanonymous_id. You do not callsetPushTokenagain.
Push permission
Section titled “Push permission”Report the OS notification permission on every launch and every resume. The SDK sends a status only when it changed.
WhisperrPushPermission toWhisperr(AuthorizationStatus status) => switch (status) { AuthorizationStatus.authorized => WhisperrPushPermission.granted, AuthorizationStatus.provisional => WhisperrPushPermission.provisional, AuthorizationStatus.notDetermined => WhisperrPushPermission.undetermined, _ => WhisperrPushPermission.denied, };
final settings = await messaging.getNotificationSettings();await Whisperr.instance .setPushPermission(toWhisperr(settings.authorizationStatus));A new status sends the event push_permission_changed with status
(authorized, provisional, denied or not_determined) and
previous_status. denied opts this device’s token out and holds it. When
you report granted or provisional again, the SDK registers it again.
Apple’s App Review guideline 4.5.4 asks for consent before promotional pushes (this is not legal advice). Show a line at the permission prompt that says notifications may include offers and reminders. Give users an in-app toggle to turn them off:
// Toggle off: opts the push token out and holds it.await Whisperr.instance.setPushPermission(WhisperrPushPermission.denied);
// Toggle on: report the real OS status again.await Whisperr.instance.setPushPermission(osStatus);While the toggle is off, report denied instead of the OS status. See
Contact consent.
Push opens
Section titled “Push opens”Report taps on Whisperr pushes, so Whisperr learns which messages work:
Future<void> onTap(RemoteMessage m, {required bool coldStart}) async { await Whisperr.instance.trackPushOpened(m.data, coldStart: coldStart); final link = WhisperrPushOpen.fromData(m.data)?.deepLink; if (link != null) router.go(link);}
FirebaseMessaging.onMessageOpenedApp.listen((m) => onTap(m, coldStart: false));final initial = await FirebaseMessaging.instance.getInitialMessage();if (initial != null) await onTap(initial, coldStart: true);trackPushOpened sends push_opened only for Whisperr pushes (the data has
whisperr_message_id), with action_id (default for a tap on the
notification) and, when you pass coldStart:, cold_start. It sends each
message once, also after a restart, so a call from both hooks is safe.
WhisperrPushOpen.fromData reads the message id and the deep link
(whisperr_deep_link, else deep_link) without sending anything.
Push receipts
Section titled “Push receipts”trackPushReceived(data) reports a Whisperr push that arrived while the app
is in the foreground (FirebaseMessaging.onMessage) as push_received, once
per message. whisperr_firebase_messaging calls it for you.
trackAutomaticEvents: false turns it off. Background receipts are not
reported.
Logout
Section titled “Logout”await Whisperr.instance.reset(); // flushes, then clears the current userFor an interactive logout, reset(flushBeforeReset: false) clears the user at
once and delivers the queue in the background.
Opt-out
Section titled “Opt-out”await Whisperr.instance.optOut(); // deletes the queue, then sends nothingawait Whisperr.instance.optIn(); // sends againoptOut() also opts this device’s push token out, so Whisperr stops sending
push to it. After optIn(), the next setPushToken registers the token
again. The SDK saves the choice across restarts and keeps it across reset().
Data already sent stays on the server. setOptOut(bool) still works but is
deprecated.
How does the Flutter SDK deliver events?
Section titled “How does the Flutter SDK deliver events?”- Ordered queue:
identifyandtrackgo into one ordered queue.identifycalls go toPOST /v1/identify.trackcalls are combined intoPOST /v1/events/batch. - Batching: the queue flushes on an interval, when it holds
flushAtevents, when the app goes to the background, or when you callflush(). - Offline: the queue is saved with
shared_preferencesand survives app restarts. - Errors: network errors,
429and5xxretry with exponential backoff. ARetry-Afteron429or503sets the wait, up to 60 seconds.401and403pause delivery and keep the queue. Other4xxdrop the request. Every drop reachesonError. See the delivery contract.
Options
Section titled “Options”await Whisperr.initialize( apiKey: 'wpk_your_publishable_key', options: const WhisperrOptions( flushInterval: Duration(seconds: 15), flushAt: 20, maxBatchSize: 500, // backend hard cap maxQueueSize: 1000, // drops oldest events beyond this enablePersistence: true, trackAutomaticEvents: true, captureErrors: true, // 0.8.0+: uncaught errors as app_error launchSourceWindow: Duration(milliseconds: 1500), // 0.8.0+: app_opened waits for its launch source restoreIdentifyDelay: Duration(seconds: 3), debug: false, ),);
await Whisperr.instance.flush(); // force deliveryThe values shown are the defaults. onError receives delivery failures
(auth, dropped, retry_exhausted).
A note on the API key
Section titled “A note on the API key”Use the publishable key (wpk_…) in your app. It ships in the app binary and
can only send data for your app. Never put a server key (wrk_…) in app code:
it can change a user’s email address and confirm consent, so it stays on your
server.
Common questions
Which API key does the Flutter SDK use?
The publishable key (wpk_…), shown as an App key in Settings → API keys. It ships in your app binary by design. Never put the server key (wrk_…) in app code.
Are events tracked before login lost?
No. Before identify() the Flutter SDK sends events under a saved random anonymous_id. The next identify() carries the same id, and Whisperr moves those events to the user.
Does the Flutter SDK send the IANA time zone?
No. Flutter cannot read the IANA zone name without a plugin, so the SDK sends timezone_offset_minutes. Pass traits: {'timezone': 'Europe/Berlin'} to identify() when your app knows the name.
Do I need whisperr_firebase_messaging for push?
No. It saves the wiring when your app uses firebase_messaging. Without it, pass the token to setPushToken() and report the permission and taps yourself.