whisperr 0.6.0
whisperr: ^0.6.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.6.0
Initialize #
Call once at startup (e.g. in main). Get an app ingestion key from the Whisperr dashboard → Developer → 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 opted-in channels:
await Whisperr.instance.identify(
'user_123',
email: 'ada@example.com',
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.
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.
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.
Whisperr.instance.track('checkout_completed', properties: {'amount': 42, 'currency': 'USD'});
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 |
app_backgrounded |
the app leaves the screen | foreground_ms |
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).
Screen views are manual. Call screen() from your router or a
NavigatorObserver:
Whisperr.instance.screen('Checkout');
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.
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
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.