# Flutter SDK

> Install the whisperr Flutter package to identify users, track events and register push tokens from iOS and Android apps.

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](/agents/). It installs the SDK and wires your events and push for you.

## Install

`pubspec.yaml`

```yaml
dependencies:
  whisperr: ^0.8.0
```

Or run `flutter pub add whisperr` to add the latest version. The package is [`whisperr` on pub.dev](https://pub.dev/packages/whisperr). It needs Flutter 3.19 or later and Dart 3.4 or later. Release notes are in the [changelog](https://github.com/WhisperrAI/whisperr-flutter/blob/main/CHANGELOG.md).

## 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**.

`lib/main.dart`

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

## 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:

```dart
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](/concepts/consent/#confirm-consent-with-identify). `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](/concepts/consent/) for the limits on addresses from the app. Send push tokens with [`setPushToken`](#push-notifications), 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

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

```dart
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.

## Track

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.

```dart
// 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](/concepts/events/). Only registered events drive interventions. See [What to track](/concepts/events/#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](/api/events/).

### 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

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):

```dart
MaterialApp(navigatorObservers: [WhisperrNavigatorObserver()]);
```

With another router, call `screen()` yourself:

```dart
Whisperr.instance.screen('Checkout'); // sends screen_viewed with screen_name
```

## 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](#launch-source)                                                                                   |
| `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

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

`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`:

```dart
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

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:

```dart
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](/privacy/sdk-data/).

## Push notifications

Whisperr sends push through your own provider account. Connect it in the dashboard first: see [Connect a push provider](/integrations/push-providers/). With `firebase_messaging`, connect Firebase Cloud Messaging.

### How do I set up push with firebase\_messaging?

[`whisperr_firebase_messaging`](https://pub.dev/packages/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.

> `whisperr_firebase_messaging` 0.1.0 works only with `whisperr` below 0.7.0. Until a version for `whisperr` 0.8.0 is on pub.dev, an app that uses it stays on that older `whisperr`. That version has no `emailOptedIn` (it needs 0.7.0), and its `email:` shortcut always sends `opted_in: true`. Send the address with `channels: [WhisperrChannel.email(email, optedIn: false)]` for a user who declined, and without `optedIn` otherwise. With **Per user**, confirm consent from your backend with the [server key](/concepts/consent/#confirm-consent-with-identify).

`pubspec.yaml`

```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`

```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:

| 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

```dart
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)`                                |

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

### Token lifecycle

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

### Push permission

Report the OS notification permission on every launch and every resume. The SDK sends a status only when it changed.

```dart
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:

```dart
// 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](/concepts/consent/#push-notifications).

### Push opens

Report taps on Whisperr pushes, so Whisperr learns which messages work:

```dart
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

`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

```dart
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.

## Opt-out

```dart
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.

## How does the Flutter SDK deliver events?

- **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](/api/delivery/).

## Options

```dart
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`).

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

Source: https://docs.whisperr.net/sdks/flutter/
