whisperr 0.8.0
whisperr: ^0.8.0 copied to clipboard
Official Whisperr SDK for Flutter — identify users and track product events to power churn-prevention interventions.
Whisperr SDK for Flutter #
Identify your users and track product events so Whisperr can decide and deliver churn-prevention interventions. Two calls do the work: identify() and track().
Install #
dependencies:
whisperr: ^0.8.0
Initialize #
Call once at startup (e.g. in main). Get an app ingestion key from the Whisperr dashboard → Settings → API keys.
import 'package:whisperr/whisperr.dart';
await Whisperr.initialize(apiKey: 'wpk_xxx');
baseUrl defaults to https://api.whisperr.net; pass it only to target a self-hosted or local backend.
Identify #
Set who the current user is. Call it after login or sign-up and on session restore at app start. It is idempotent. Traits are merged server-side; channels are how Whisperr can reach the user (and whether it's allowed to).
// Common case — email/phone/pushToken expand into channels:
await Whisperr.instance.identify(
'user_123',
email: 'ada@example.com',
// Only when your user agreed to email (see "Email consent" below):
// emailOptedIn: true,
phone: '+15551234567',
pushToken: fcmToken, // expands to an opted-in push channel
traits: {'name': 'Ada', 'plan': 'pro'},
);
// Full control — consent and verification:
await Whisperr.instance.identify(
'user_123',
channels: [
WhisperrChannel.email('ada@example.com', verified: true),
WhisperrChannel.sms('+15551234567', optedIn: false), // opted out of SMS
],
);
Whisperr decides which channel to actually use based on engagement — there's no "preferred channel" to set. Express an explicit user choice via
optedIn: falseon the channels they don't want.
Email consent #
The email shortcut stores the address and claims no consent. Pass emailOptedIn (0.7.0 or later) with your user's answer when you have it: it is sent as opted_in on the email channel, and omitting it sends nothing. Whether the address then gets messages depends on your app's answer to "Does your signup get permission to email users?" in Whisperr:
- Yes: the address is opted in unless you pass
emailOptedIn: false. - Per user: only
emailOptedIn: trueopts it in. - No: the address gets no messages until your server confirms it.
An earlier opt-out, including the unsubscribe link, is kept: a later identify from the app never opts the address back in. An address that another user in the app already holds stays pending. A new address can also stay pending when the app has reached its hourly limit of new email addresses from the app.
If your app requires a server key for email (the stricter mode), only your backend can opt an address in, with the server key. Never put the server key in your app.
Restore at start #
The SDK stores the user id. At each start with a stored user, it sends one
restore identify (external_user_id, anonymous_id, the device traits
and the app language; no channels), so Whisperr knows the user still uses
the app on this device. It waits 3 seconds
(WhisperrOptions.restoreIdentifyDelay): your own identify() for the user
in that time replaces it. It goes through the same offline queue. It is
skipped while opted out and when the same traits went out for the user less
than 24 hours ago.
The SDK cannot know a user who signed in before you added the SDK. Call
identify() on session restore, so these users are identified on their
first launch with the SDK.
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 changes _
to -, stores the tag, and sends it as the trait app_language: under the
user, or before login under the device anonymous_id. Every identify()
carries it; a traits['app_language'] you pass wins. null clears the
stored value. An invalid tag is ignored. reset() keeps it.
Device traits #
identify() also sends traits['locale'] (BCP 47, from the platform locale) and traits['timezone_offset_minutes'] (the device's current UTC offset — Flutter can't obtain an IANA zone name without a plugin) by default; pass your own traits['timezone'] (an IANA name such as Europe/Berlin) or traits['locale'] to override, and nothing is sent for a value the platform can't provide.
If your app already reads the IANA zone (for example with a timezone plugin), pass it to identify(). The SDK adds no dependency for this. A timezone you pass replaces the offset fallback:
await Whisperr.instance.identify('user_123', traits: {'timezone': 'Europe/Berlin'});
Push notifications #
Using firebase_messaging? Add
whisperr_firebase_messaging.
It asks for permission, registers the token with its kind, keeps both current,
and tracks notification taps with deep links:
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:whisperr_firebase_messaging/whisperr_firebase_messaging.dart';
final messaging = FirebaseMessaging.instance;
await Whisperr.instance.registerFirebaseMessaging(messaging);
await Whisperr.instance.handleNotificationOpens(
messaging,
onOpen: (open, message) {
if (open.deepLink != null) router.go(open.deepLink!);
},
);
The rest of this section is for apps that wire push by hand. The core SDK
never bundles a push library: hand it the token your messaging setup produces,
and Whisperr keeps the push channel current.
Token and kind #
final messaging = FirebaseMessaging.instance;
// Current token (safe on every launch — repeats are a no-op):
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. Send what you know:
| 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) |
- Every token carries
platform(the OS the app runs on) andclient_app_id(the bundle id or package name, frompackage_info_plus). 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.providerProjectis the token's provider project: the Firebase project id for an FCM token (whisperr_firebase_messagingsends it for you). It never goes with an APNs token.- Without
kind, the server infers the kind from the token format.
Token lifecycle #
- Called after login,
setPushTokenre-identifies the push channel immediately. - 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()attaches it to the user. - Repeats are deduped across restarts: the last-sent (user or anonymous
id, token) pair is persisted alongside the queue, so calling
getToken()+setPushTokenon every launch never re-sends an identify for an unchanged token. A token with less metadata than the one sent (a bare token after an FCM token) is not sent again either. - Every 7 days the SDK sends the same token once more (at launch or on
the next
setPushToken), so Whisperr knows it is still live. - Token rotation is handled: the previously sent token is opted out and the new one opted in, so stale tokens don't accumulate — and tokens from the user's other devices are never touched.
reset()(logout) moves the token: it opts the token out under the old user and opts it in under the newanonymous_id. The next user'sidentify()gets it. You do not callsetPushTokenagain.
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));
- The SDK sends the event
push_permission_changedwithstatus(authorized,provisional,deniedornot_determined). When the status changed,previous_statusholds the status sent before. - The SDK stores the last status it sent on this device. The same status is
not sent again, also after a restart.
reset()forgets it, so the next user gets a new report. - Before login, the event goes out under the device's
anonymousId. - The event goes out also when automatic events are off.
deniedopts this device's token out, so the engine does not choose push for it. While the status isdenied,setPushTokenholds the token back. When you reportgrantedorprovisionalagain, the SDK registers it again.
Push opens #
Report push taps, 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 (actionId:, the tapped button;
default for a tap on the notification) and cold_start when you pass
coldStart: (true when the tap launched the app). It ignores a message id it
already reported, so calling it 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 #
trackPushReceived(data) reports a Whisperr push that arrived while the app
is in the foreground (FirebaseMessaging.onMessage) as push_received, once
per message id. whisperr_firebase_messaging calls it for you. It is an
automatic event: trackAutomaticEvents: false turns it off. Background
receipts are not reported.
Track #
Record product events. Buffered and sent in batches; the timestamp is captured at call time, so events recorded offline keep their real time.
// Your app's core action. This event name is only an example.
Whisperr.instance.track('report_exported', properties: {'format': 'pdf'});
Event names must be
snake_case. Only events that map to the events you configured during onboarding drive interventions; others are accepted but inert.
You can call track() before identify(). The SDK sends the event under a
device anonymous_id. The next identify() carries the same id, so Whisperr
merges those events into the user. reset() starts a new anonymous id.
Every event (yours and the automatic ones) carries app_version,
app_build, platform, sdk_name and sdk_version in its context. A key
the platform cannot provide is left out; a key you pass in context: wins.
Automatic events #
The SDK sends these events for you. 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 for a link or push launch whisperr_message_id, link_route, link_host, utm_source / utm_medium / utm_campaign when known; first_after_update: true on the first open after an update |
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 |
Every SDK-generated event (also screen_viewed, push_opened and
push_received) carries
sdk_name (whisperr-flutter), sdk_version, app_version, app_build,
platform and os_name (the OS family: ios, android, web), os_version,
locale and timezone_offset_minutes. timezone is sent only when it is a
real IANA name. Flutter cannot read the IANA zone without a plugin, so most
apps get the offset only. A key the platform cannot provide is
left out (for example os_version on Android). Turn the automatic events off
with WhisperrOptions(trackAutomaticEvents: false).
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.
Screens #
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');
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() (or by whisperr_firebase_messaging) 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_* are copied when present.
wsrc=widget or wsrc=shortcut on the link marks a widget or shortcut
launch, and wmid marks a Whisperr email.
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.
Logout #
await Whisperr.instance.reset(flushBeforeReset: false); // clears identity locally; drains in background
Opt-out #
await Whisperr.instance.optOut(); // deletes the queue, then sends nothing
await Whisperr.instance.optIn(); // sends again
optOut()tells the server to stop push to this device. When the SDK registered a push token, it sends one identify that opts the token out (opted_in: false) under the user it was registered for. That user can differ from the current user, for example afteridentify(userB)withoutreset(). Push opt-outs already queued (a token rotation, a denied permission) stay queued ahead of it. The SDK delivers and retries these requests like any queued call, also after a restart.- After that, the SDK sends nothing until
optIn(). It drops a buffered push token. - After
optIn(), the nextsetPushTokenregisters the token again. - Email, SMS and the user's other devices keep their state. Data already sent stays on the server.
- The choice is persisted across restarts and kept across
reset(). setOptOut(bool)still works. It is deprecated: useoptOut()andoptIn().
How delivery works #
- Durable queue —
identifyandtrackare appended to an ordered queue and delivered in order.identifycalls hitPOST /v1/identify;trackcalls are coalesced intoPOST /v1/events/batch. - Batching — flushes on an interval (
flushInterval), when the buffer hitsflushAt, when the app goes to the background (hidden/pause/detach), or when you callflush(). - Offline — the queue is persisted (via
shared_preferences) and survives app restarts. Transient failures (network, 429, 5xx) retry with exponential backoff; aRetry-Afteron 429/503 replaces the backoff (capped at 60 s); auth errors (401/403) pause delivery and keep the queue; permanent client errors (4xx) drop the offending item so the queue keeps moving. Every drop reachesonError; the first drop for each status code also logs adebugPrintwarning, even withdebugoff.
Options #
await Whisperr.initialize(
apiKey: 'wpk_xxx',
options: const WhisperrOptions(
flushInterval: Duration(seconds: 15),
flushAt: 20,
maxBatchSize: 500, // backend hard cap
maxQueueSize: 1000, // drops oldest beyond this
enablePersistence: true,
trackAutomaticEvents: true, // app_installed / updated / opened / backgrounded, push_received
restoreIdentifyDelay: Duration(seconds: 3), // wait for your own identify() at start
captureErrors: true, // uncaught errors as app_error
launchSourceWindow: Duration(milliseconds: 1500), // app_opened waits for its launch source
debug: false,
),
);
await Whisperr.instance.flush(); // force delivery (e.g. before a critical await)
A note on the API key #
The ingestion key is embedded in your app, like a Segment write key or Amplitude API key. It can only ingest events for your app; treat it as publishable, not secret.
Durable channel changes #
For logout/token revocations that your application journals, use
identify(userId, channels: [...], requirePersistence: true). Clear the journal
only after the call succeeds. A successful call confirms that the configured
persistence implementation saved the queue; it does not confirm network delivery
or server acceptance. Disabled persistence, storage failure, and a queue filled
with identify operations reject the call so the application can retain its journal
and retry. Invalid server requests can still be rejected permanently.
Queue capacity remains bounded. Telemetry events may be evicted; pending identify
operations are protected. Normal identify() calls also reject when all queue
slots contain identifies. Token stream forwarding catches and reports these errors.