whisperr 0.5.0
whisperr: ^0.5.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.5.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: 'wrk_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. Idempotent and safe to call on every login. 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.
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) |
- With any metadata,
platformdefaults to the OS the app runs on. pushEnvis the APNs environment:sandboxfor development-signed builds,productionfor TestFlight and the App Store. The SDK never guesses it.setPushToken(token)without metadata sends the token only. The server then infers the kind from its format.
Token lifecycle #
- Called after login,
setPushTokenre-identifies the push channel immediately. - Called before login, the token is buffered and attached to the next
identify(). - Repeats are deduped across restarts: the last-sent (user, token) pair is
persisted alongside the queue, so calling
getToken()+setPushTokenon every launch never re-sends an identify for an unchanged token. - 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.
- After
reset()(logout), callsetPushTokenagain once the next user logs in.
Permission #
Report the OS notification permission on every launch and every resume. A repeated status is a no-op.
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 user gets the trait
push_permission. 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.- Before login, the status goes with the next
identify().
Push opens #
Report push taps, so Whisperr learns which messages work:
Future<void> onTap(RemoteMessage m) async {
await Whisperr.instance.trackPushOpened(m.data);
final link = WhisperrPushOpen.fromData(m.data)?.deepLink;
if (link != null) router.go(link);
}
FirebaseMessaging.onMessageOpenedApp.listen(onTap);
final initial = await FirebaseMessaging.instance.getInitialMessage();
if (initial != null) await onTap(initial);
trackPushOpened sends push_opened only for Whisperr pushes (the data has
whisperr_message_id). 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.
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.
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 and push_opened) 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.setOptOut(true); // deletes the queue, sends nothing
await Whisperr.instance.setOptOut(false); // sends again
The choice is persisted across restarts.
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: 'wrk_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
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.