Skip to content

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.

pubspec.yaml
dependencies:
whisperr: ^0.8.0

Or 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.

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.

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

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.

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.

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.

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.

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_name

The Flutter SDK sends these events. You write no code.

EventWhenOwn properties
app_installedfirst launchapp_version, app_build
app_updatedfirst launch of a new version or buildapp_version, app_build, previous_version, previous_build
app_openedlaunch, and each return from backgroundcold_start, launch_source, and the launch details
app_backgroundedthe app leaves the screenforeground_ms, session_id, and the counts for that visit: screen_count, unique_screen_count, event_count (your track() events), error_count
app_erroran uncaught Dart or Flutter error, or trackError()error_kind, fatal, error_name (the type name, never the message), fingerprint, screen_name, session_id
app_crashednext launch after the app ended while in the foregroundcrash_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.

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.

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.

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.

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.

pubspec.yaml
dependencies:
whisperr: ^0.6.0
whisperr_firebase_messaging: ^0.1.0
firebase_messaging: ^16.0.0

Set up Firebase for your app first (flutterfire configure). On iOS, enable the Push Notifications capability and upload your APNs key to Firebase.

lib/main.dart
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:

OptionDefaultWhat it does
requestPermissiontrueShow the OS prompt. false only reads the permission.
provisionalfalseiOS quiet delivery without a prompt.
useApnsTokenfalseOn iOS, register the raw APNs token (kind: apns) instead of the FCM token. Use it only when Whisperr sends through APNs directly.
apnsEnvironmentnoneWith useApnsToken: sandbox for development-signed builds, production for TestFlight and the App Store. The package cannot read it, so pass it.
watchPermissiontrueCheck the permission again on each resume.
trackForegroundReceiptstrueReport 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.

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:

TokenCall
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 idsetPushToken(id, kind: WhisperrPushTokenKind.oneSignalSubscription)
  • pushEnv is the APNs environment: sandbox for development-signed builds, production for 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.
  • providerProject is the Firebase project id for an FCM token. whisperr_firebase_messaging sends it for you.
  • Every token also carries the platform and the bundle id or package name (client_app_id, from package_info_plus).
  • Without kind, Whisperr infers the kind from the token’s format.
  • Called after login, setPushToken sends 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 next identify() 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 new anonymous_id. You do not call setPushToken again.

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.

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.

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.

await Whisperr.instance.reset(); // flushes, then clears the current user

For an interactive logout, reset(flushBeforeReset: false) clears the user at once and delivers the queue in the background.

await Whisperr.instance.optOut(); // deletes the queue, then sends nothing
await Whisperr.instance.optIn(); // sends again

optOut() 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.

  • Ordered queue: identify and track go into one ordered queue. identify calls go to POST /v1/identify. track calls are combined into POST /v1/events/batch.
  • Batching: the queue flushes on an interval, when it holds flushAt events, when the app goes to the background, or when you call flush().
  • Offline: the queue is saved with shared_preferences and survives app restarts.
  • Errors: network errors, 429 and 5xx retry with exponential backoff. A Retry-After on 429 or 503 sets the wait, up to 60 seconds. 401 and 403 pause delivery and keep the queue. Other 4xx drop the request. Every drop reaches onError. See the delivery contract.
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 delivery

The values shown are the defaults. onError receives delivery failures (auth, dropped, retry_exhausted).

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.