# React Native SDK

> Install @whisperr/react-native to identify users, track events and register push tokens from Expo and bare React Native apps.

`@whisperr/react-native` sends identify calls, product events and push tokens from a React Native app to Whisperr. Use this page to install it, identify users, track events and wire push notifications in Expo or bare React Native.

The SDK is plain TypeScript with no native code and no dependencies. It works in Expo Go, bare React Native and development builds, with no linking, config plugin or prebuild. Remote push on Android needs a development build from Expo SDK 53.

For push in an Expo app, also install [`@whisperr/expo`](/sdks/expo/). It adds a config plugin, a push registration helper and a hook for notification taps. Events and identify need only `@whisperr/react-native`.

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

```bash
npm i @whisperr/react-native @react-native-async-storage/async-storage
```

In Expo, run `npx expo install @react-native-async-storage/async-storage` for the storage package. The SDK is [`@whisperr/react-native` on npm](https://www.npmjs.com/package/@whisperr/react-native). It needs React 17 or later and React Native 0.65 or later. Release notes are in the [changelog](https://github.com/WhisperrAI/whisperr-react-native/blob/main/CHANGELOG.md).

## Quickstart

Create an **App** key (`wpk_…`, the publishable key) in the Whisperr dashboard under **Settings → API keys**.

`App.tsx`

```tsx
import { Whisperr } from "@whisperr/react-native";


// Uses AsyncStorage automatically when it is installed.
const whisperr = Whisperr.init({ apiKey: "wpk_your_publishable_key" });


// After the user logs in, and on every app start when a user is signed in:
whisperr.identify(user.id, {
  traits: { first_name: user.firstName, plan: user.plan },
  email: user.email,
  emailOptedIn: user.agreedToEmail, // the user's answer in your app; leave it out when the app does not ask
});


// When the user does something in your app. The name is an example:
// use your app's own action.
whisperr.track("photo_uploaded", { album_size: 12 });


// On logout:
whisperr.reset();
```

`Whisperr.init()` returns one shared client, and calling it again returns the same client. Construct `WhisperrClient` yourself for explicit lifetimes, and call `close()` when you are done with it.

## Storage

The SDK keeps the user id, the anonymous id, the queue, the push token, the opt-out choice and the app language on the device.

- **Default:** when `@react-native-async-storage/async-storage` is installed, the SDK uses it. You pass nothing.
- **Your own adapter:** pass any object with `getItem`, `setItem` and `removeItem` (for example MMKV or `expo-sqlite/kv-store`) as `storage`. It wins over AsyncStorage.
- **Neither:** the SDK works in memory only. The user id is lost at each app restart, and events captured right before the app is killed are lost. The SDK logs one warning in development.

```ts
Whisperr.init({ apiKey: "wpk_your_publishable_key", storage: myMmkvAdapter });
```

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

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:

```ts
whisperr.identify(user.id, {
  traits: { first_name: user.firstName, plan: user.plan },
  channels: [
    { type: "email", address: user.email, optedIn: user.emailOptedOut ? false : undefined },
  ],
});
```

The React Native SDK uses the publishable key, which anyone can read from the app bundle. What Whisperr does with an email address from the app depends on your answer to **Does your signup get permission to email users?**:

- **Yes:** the address is opted in, unless the app sends `optedIn: false`.
- **Per user:** the address is opted in only when the app sends `optedIn: true`.
- **No**, not answered, or **Require a server key** is on: the address is stored as pending. Confirm consent from your backend with a [server SDK](/concepts/consent/#confirm-consent-with-identify).

App-side consent needs `@whisperr/react-native` 0.6.0 or later. Older versions cannot set it: Whisperr stores the address as pending. The shortcut `identify(user.id, { email: user.email, emailOptedIn: true })` sends the same email channel. `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. Whisperr ignores `verified: true` from the app. See [Contact consent](/concepts/consent/) for the full rules and the limits on app-set addresses. 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 SDK treats every `403` like a rejected key: it reports an `auth` error (with status `401`), 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 `timezone` (IANA name) and `locale` (BCP 47) as traits, when the runtime can read them. Whisperr uses them for quiet hours and message language. Pass your own `traits.timezone` or `traits.locale` to override them.

### Restore identify at app start

At each app start, the SDK identifies the stored user again with the device time zone, locale and app language, so they stay current while the user stays signed in. It skips this when you call `identify()` right after `Whisperr.init()`, while opted out, and when the same values reached the server 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

Tell Whisperr the language the user picked in your app. It is not the device locale. Whisperr writes messages in this language.

```ts
whisperr.setAppLanguage("pt-BR"); // BCP 47; "pt_BR" also works
whisperr.setAppLanguage(null);    // clear the stored value
```

The SDK stores it and sends it at once, also before login. Every later `identify()` carries it as `traits.app_language`. A `traits.app_language` that you pass wins. The same value again sends nothing. An invalid tag is ignored.

## Track

```ts
// An example name. Use your app's own action.
whisperr.track("photo_uploaded", { album_size: 12 });
```

Event names must be lowercase `snake_case`. The SDK drops other names before they enter the queue and reports them to `onError`. See [Event design](/concepts/events/).

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 at once 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

```ts
whisperr.screen("Paywall", { plan: "pro" }); // tracks screen_viewed { screen_name: "Paywall", plan: "pro" }
```

Wire it to your navigator once. From version 0.7.0, with React Navigation:

```tsx
import { whisperrNavigationTracker } from "@whisperr/react-native";


const navigationRef = useNavigationContainerRef();
const onNavigation = useMemo(() => whisperrNavigationTracker(navigationRef), [navigationRef]);


<NavigationContainer ref={navigationRef} onReady={onNavigation} onStateChange={onNavigation}>
```

It sends the focused route’s name, skips a repeat of the same name, and reports to the `Whisperr.init()` client (pass `{ client }` for another one). With expo-router, call [`useWhisperrScreens()`](/sdks/expo/#screens-with-expo-router) from `@whisperr/expo` in your root layout instead. Screen names should be route names, never titles that contain user data.

### Provider and hook

`App.tsx`

```tsx
import { Button } from "react-native";
import { WhisperrProvider, useWhisperr } from "@whisperr/react-native";


export default function App() {
  return (
    <WhisperrProvider options={{ apiKey: "wpk_your_publishable_key" }}>
      <Root />
    </WhisperrProvider>
  );
}


function SaveButton({ onSave }: { onSave: () => Promise<void> }) {
  const whisperr = useWhisperr();
  // Track after the action succeeds. "note_saved" is an example name.
  return <Button onPress={async () => { await onSave(); whisperr.track("note_saved"); }} title="Save" />;
}
```

## Automatic events

On by default. Turn them off with `trackAppLifecycleEvents: false`.

| Event              | Sent when                                              | Extra properties                                                                                              |
| ------------------ | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- |
| `app_installed`    | First launch of a fresh install                        | none                                                                                                          |
| `app_updated`      | First launch after the app version or build changed    | `previous_version`, `previous_build`                                                                          |
| `app_opened`       | The app comes to the foreground                        | `cold_start`, `launch_source` and the [launch details](#launch-source)                                        |
| `app_backgrounded` | The app goes to the background                         | `foreground_ms`, `session_id`, `screen_count`, `unique_screen_count`, `event_count`, `error_count`            |
| `app_error`        | An uncaught JS error, or a `trackError()` call         | `error_kind`, `fatal`, `error_name`, `fingerprint`, `screen_name`, `session_id`                               |
| `app_crashed`      | The first launch after the app ended in the foreground | `crash_kind`, `detected_by`, `crashed_app_version`, `crashed_session_id`, `crashed_screen_name`, `foreground` |
| `push_received`    | You call `trackPushReceived()` for a Whisperr push     | `whisperr_message_id`, `foreground`, `source`                                                                 |

Each automatic event also carries `app_version` and `app_build`, `platform` and `os_name`, `os_version`, `sdk_name` and `sdk_version`, `locale`, and `timezone` (or `timezone_offset_minutes` when the runtime has no IANA name). Every event, also your own, carries `app_version`, `app_build`, `platform`, `sdk_name`, `sdk_version` and a `session_id` in its `context`.

The SDK has no native code, so it reads the app version and build from the `appVersion` and `appBuild` options, else from `expo-application`, else from `react-native-device-info`, when the app has one of them. Otherwise it leaves them out. Install and update detection need durable storage.

From version 0.7.0, the SDK also sends sessions, the launch source, and errors and crashes, as described below.

### Sessions

Every event carries `context.session_id` (`sess_<uuid>`). A new session starts on every cold start and after 30 minutes with no event. `app_backgrounded` repeats the id and counts what the foreground visit did: `screen_viewed` events (`screen_count`), distinct screen names (`unique_screen_count`), your own `track()` events (`event_count`; automatic events are not counted), and `app_error`s (`error_count`, including the ones the limits below drop). `foreground_ms` uses a monotonic clock.

### Launch source

`app_opened` says where the open came from in `launch_source`: `push`, `shortcut`, `widget`, `email`, `universal_link`, `deep_link`, or `direct` (nothing else detected). When several arrive, that is also the order of precedence.

- **Push:** a `trackPushOpened()` call for the tap that opened the app (with `@whisperr/expo` this is automatic). Adds `whisperr_message_id`.
- **Links:** the SDK reads `Linking.getInitialURL()` and listens for `Linking` `url` events itself. An `https` link is a `universal_link` (with `link_host`), any other scheme a `deep_link`. A link with `wsrc=widget` or `wsrc=shortcut` in its query is a `widget` or `shortcut` open, so give your widget and quick-action URLs that parameter (for example `myapp://home?wsrc=widget`). A link with `wmid=<message id>` is an `email` open. Links add `link_route`, the path with the query and fragment dropped and id-like segments (digits, UUIDs, 16+ characters) replaced by `:id`, and `utm_source`, `utm_medium` and `utm_campaign` when the link has them.
- `first_after_update: true` marks the first open after `app_updated`.

The tap or URL can arrive just after the app comes to the foreground, so the SDK holds `app_opened` for up to 1.5 seconds (`launchSourceWindowMs`). It sends it as soon as a source resolves, when the app goes back to the background, or when you call `flush()`.

### Errors and crashes

The SDK installs React Native’s global JS error handler (`ErrorUtils`) and always calls the handler that was there before it, so the red box, Sentry or Crashlytics keep working. Each error becomes an `app_error` with its type (`error_name`, such as `TypeError`) and a `fingerprint` (a hash of the type and the top three function names of the stack). The message, the stack, file paths and URLs are never sent. At most one `app_error` per fingerprint and 20 per session are sent; they go out with the next batch.

Report errors your code handles with `trackError()`:

```ts
try {
  await loadCart();
} catch (error) {
  whisperr.trackError(error, { screen: "Cart" }); // error_kind: "handled"
}
whisperr.trackError({ name: "HTTP_503" }, { kind: "network" }); // a failed request
```

`kind` is `handled` (default), `network` or `screen`; `screen` defaults to the last `screen_viewed`.

`app_crashed` is sent on the next launch, before its `app_opened`, when the app ended while it was in the foreground (`crash_kind: "abnormal_exit"`, `detected_by: "unclean_shutdown"`). When the last thing the app did was a fatal JS error, it is `crash_kind: "crash"`, `detected_by: "js_fatal"`. The SDK has no native code, so it cannot see the cause of a native crash, and a process the OS kills in the foreground also counts. Development builds (`__DEV__`) never send `app_crashed`.

Unhandled promise rejections are not captured unless you pass `captureUnhandledRejections: true` (release builds on Hermes). Hermes keeps only one rejection tracker per app and offers no way to pass rejections on, so turning it on replaces the tracker of Sentry, Bugsnag or any other tool. Turn it on only when no other tool tracks rejections.

`captureErrors: false` turns off the global handler (and the rejection tracking). `trackAppLifecycleEvents: false` turns off automatic error capture and `app_crashed` as well. `trackError()` works either way. Like every event, errors stop after `optOut()`. 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/). Then hand the SDK the token your messaging setup produces. The SDK never bundles a push library. In an Expo app, [`@whisperr/expo`](/sdks/expo/#register-for-push) gets the token and the permission for you.

### Token kinds

The kind tells Whisperr which provider can send to the token. Send it when you know it:

| Token                                    | Call                                                                                                                                                         |
| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Expo push token (`ExponentPushToken[…]`) | `setPushToken({ token })`, detected as `expo`                                                                                                                |
| expo-notifications token object          | `setPushToken(await Notifications.getExpoPushTokenAsync({ projectId }))` sends `expo`. `getDevicePushTokenAsync()` sends `apns` on iOS and `fcm` on Android. |
| FCM registration token                   | `setPushToken({ token, kind: "fcm" })`                                                                                                                       |
| Raw APNs device token (hex)              | `setPushToken({ token, kind: "apns", pushEnv: "production" })`                                                                                               |
| OneSignal subscription id                | `setPushToken({ token, kind: "onesignal_sub" })`                                                                                                             |

- `pushEnv` is the APNs environment: `sandbox` for development-signed builds, `production` for TestFlight and the App Store. The SDK cannot read it, so set it from your build configuration. Without it, Whisperr cannot tell a sandbox token from a production token.
- The SDK also sends the `platform`, the bundle id or package name (`clientAppId` option, else `expo-application` or `react-native-device-info`) and, for Expo and FCM tokens, the EAS or Firebase project id. Whisperr uses them to catch a token from the wrong app or project before a send.
- A plain string (`setPushToken(token)`) sends no kind. Whisperr then infers the kind from the token’s format.
- An APNs token needs an APNs provider in Whisperr. An Expo token needs the Expo provider.

### Token lifecycle

- `setPushToken` is safe to call on every launch and from a refresh listener. The same token again sends nothing, also after a restart.
- **Before login**, the token goes out at once under the anonymous id. The next `identify()` moves it to the user, so the device can get push before the user signs in.
- **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.
- The SDK sends the same token again at most once every 7 days, at launch, so Whisperr can tell a live device from a stale one.

### Push permission

Report the OS notification permission on every launch and on every return to the foreground. A repeated status sends nothing.

```ts
whisperr.setPushPermission("granted"); // "granted" | "provisional" | "denied" | "undetermined"
```

To show the OS prompt, call `requestPushAuthorization()`. It uses `expo-notifications` or `@react-native-firebase/messaging`, whichever the app has, and reports the answer for you. It returns `null` when it finds neither. With `{ provisional: true }`, iOS grants quiet delivery without a prompt.

```ts
const status = await whisperr.requestPushAuthorization();
```

A new status sends the event `push_permission_changed`. `"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:

```ts
// Toggle off: opts the push token out and holds it.
whisperr.setPushPermission("denied");


// Toggle on: report the real OS status again
// ("granted", "provisional", "denied" or "undetermined").
whisperr.setPushPermission(osStatus);
```

While the toggle is off, report `"denied"` instead of the OS status. See [Contact consent](/concepts/consent/#push-notifications).

### How do I set up push with expo-notifications?

Register the Expo push token and report the permission. Connect the Expo provider in Whisperr.

```tsx
import { useEffect } from "react";
import { AppState, Platform } from "react-native";
import Constants from "expo-constants";
import * as Notifications from "expo-notifications";
import type { PushPermissionStatus } from "@whisperr/react-native";


function toStatus(p: Notifications.NotificationPermissionsStatus): PushPermissionStatus {
  const ios = p.ios?.status;
  if (ios === Notifications.IosAuthorizationStatus.PROVISIONAL) return "provisional";
  if (ios === Notifications.IosAuthorizationStatus.EPHEMERAL) return "granted";
  if (p.status === "granted") return "granted";
  if (p.status === "denied") return "denied";
  return "undetermined";
}


async function registerPush() {
  // Android 13+ shows the permission prompt only when a notification channel exists.
  if (Platform.OS === "android") {
    await Notifications.setNotificationChannelAsync("default", {
      name: "Default",
      importance: Notifications.AndroidImportance.DEFAULT,
    });
  }
  const status = await whisperr.requestPushAuthorization();
  if (status !== "granted" && status !== "provisional") return;
  const projectId = Constants.expoConfig?.extra?.eas?.projectId;
  whisperr.setPushToken(await Notifications.getExpoPushTokenAsync({ projectId }));
}


useEffect(() => {
  void registerPush();
  // Users change the permission in Settings: check again on each foreground.
  const sub = AppState.addEventListener("change", async (state) => {
    if (state === "active") whisperr.setPushPermission(toStatus(await Notifications.getPermissionsAsync()));
  });
  return () => sub.remove();
}, []);
```

Report taps:

```tsx
useEffect(() => {
  // Cold start: the tap that launched the app.
  Notifications.getLastNotificationResponseAsync().then((response) => {
    if (response) whisperr.trackPushOpened(response, { coldStart: true });
  });
  // Taps while the app runs or is in the background.
  const sub = Notifications.addNotificationResponseReceivedListener((response) => {
    whisperr.trackPushOpened(response);
  });
  return () => sub.remove();
}, []);
```

### How do I set up push with @react-native-firebase/messaging?

Firebase gives an FCM token on both platforms. Connect Firebase Cloud Messaging in Whisperr.

```tsx
import { useEffect } from "react";
import { AppState } from "react-native";
import messaging from "@react-native-firebase/messaging";
import type { PushPermissionStatus } from "@whisperr/react-native";


// messaging.AuthorizationStatus: -1 NOT_DETERMINED, 0 DENIED, 1 AUTHORIZED, 2 PROVISIONAL, 3 EPHEMERAL
function toStatus(status: number): PushPermissionStatus {
  if (status === messaging.AuthorizationStatus.PROVISIONAL) return "provisional";
  if (status === messaging.AuthorizationStatus.AUTHORIZED || status === messaging.AuthorizationStatus.EPHEMERAL) return "granted";
  if (status === messaging.AuthorizationStatus.DENIED) return "denied";
  return "undetermined";
}


async function registerPush() {
  // On Android 13+, also request POST_NOTIFICATIONS (PermissionsAndroid).
  whisperr.setPushPermission(toStatus(await messaging().requestPermission()));
  whisperr.setPushToken({ token: await messaging().getToken(), kind: "fcm" });
}


useEffect(() => {
  void registerPush();
  const unsubscribe = messaging().onTokenRefresh((token) => whisperr.setPushToken({ token, kind: "fcm" }));
  // Users change the permission in Settings: check again on each foreground.
  const sub = AppState.addEventListener("change", async (state) => {
    if (state === "active") whisperr.setPushPermission(toStatus(await messaging().hasPermission()));
  });
  return () => {
    unsubscribe();
    sub.remove();
  };
}, []);
```

Report taps:

```tsx
import { Linking } from "react-native";
import type { FirebaseMessagingTypes } from "@react-native-firebase/messaging";


function openPush(message: FirebaseMessagingTypes.RemoteMessage) {
  const open = whisperr.trackPushOpened(message);
  if (open?.deepLink) void Linking.openURL(open.deepLink);
}


useEffect(() => {
  // Cold start: the notification that launched the app.
  messaging().getInitialNotification().then((message) => message && openPush(message));
  // Taps that bring the app back from the background.
  return messaging().onNotificationOpenedApp(openPush);
}, []);
```

### Push opens

`trackPushOpened()` reads `whisperr_message_id` and the deep link (`whisperr_deep_link`, or `deep_link`) from the payload and sends `push_opened` once per message, also across restarts. Whisperr learns from it which messages work. Pass the data map, or the whole response or message object. A push without `whisperr_message_id` did not come from Whisperr and is ignored.

It returns `{ messageId, deepLink, actionId }` for a Whisperr push, or `null`. Route to `deepLink` yourself. `push_opened` also carries `action_id` (`default` for a tap on the notification, else the button id) and `cold_start`. Pass `{ coldStart: true }` when the tap launched the app. Without it, the SDK uses `true` for an open in the first 10 seconds after start with no background since. `parseWhisperrPush(data)` reads the same fields without sending anything.

### Push receipts

Call `trackPushReceived()` from your foreground notification listener. It sends `push_received` once per message, with `foreground` and `source`. `trackAppLifecycleEvents: false` turns it off.

```ts
messaging().onMessage((message) => whisperr.trackPushReceived(message));
```

## Opt-out

```ts
whisperr.optOut(); // stops all capture, automatic events included
whisperr.optIn();  // sends again
```

The choice is saved across launches. When this device registered a push token, `optOut()` first opts that token out, so Whisperr stops sending push to this device. Data already sent stays on the server. After `optIn()`, call `setPushToken()` again to register the token.

## How does the React Native SDK deliver events?

- Events go to `POST /v1/events/batch`, identify calls to `POST /v1/identify`.
- Each event carries a stable `$message_id`, so a resend after a restart does not create a duplicate.
- `401` and `403` pause delivery and keep the queue (`auth`). `429`, `5xx` and network errors retry with backoff, then keep the queue (`retry_exhausted`). Other `4xx` drop the batch (`dropped`). Every drop reaches `onError`.
- A `Retry-After` on `429` or `503` sets the wait, up to 60 seconds.
- The queue flushes when the app goes to the background.

See the [delivery contract](/api/delivery/).

## Options

| Option                        | Default                      | Notes                                                                                                                                   |
| ----------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `apiKey`                      | none                         | Publishable key (`wpk_…`). Required.                                                                                                    |
| `storage`                     | AsyncStorage, when installed | `getItem` / `setItem` / `removeItem` adapter for the durable queue.                                                                     |
| `baseUrl`                     | `https://api.whisperr.net`   | Ingestion base URL.                                                                                                                     |
| `flushAt` / `flushIntervalMs` | `20` / `10000`               | Batch triggers.                                                                                                                         |
| `flushOnAppBackground`        | `true`                       | Flush when the app leaves the foreground.                                                                                               |
| `trackAppLifecycleEvents`     | `true`                       | Send the automatic app events.                                                                                                          |
| `appVersion` / `appBuild`     | none                         | App version and build for the automatic events.                                                                                         |
| `captureErrors`               | `true`                       | From 0.7.0. `app_error` from uncaught JS errors.                                                                                        |
| `captureUnhandledRejections`  | `false`                      | From 0.7.0. `app_error` from unhandled promise rejections (release builds on Hermes). Replaces another tool’s Hermes rejection tracker. |
| `launchSourceWindowMs`        | `1500`                       | From 0.7.0. How long `app_opened` waits for its launch source.                                                                          |
| `maxQueueSize`                | `1000`                       | Oldest events drop beyond this.                                                                                                         |
| `maxBatchSize`                | `500`                        | Events per batch request. 500 is the backend cap.                                                                                       |
| `maxRetries`                  | `6`                          | Retries before a batch is kept for the next flush.                                                                                      |
| `requestTimeoutMs`            | `10000`                      | Timeout per request.                                                                                                                    |
| `clientAppId`                 | none                         | Bundle id or package name sent with push tokens.                                                                                        |
| `disabled`                    | `false`                      | Turns the client into a no-op.                                                                                                          |
| `debug`                       | `false`                      | Verbose logging.                                                                                                                        |
| `onError`                     | none                         | 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 bundle 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

### Does @whisperr/react-native work in Expo Go?

Yes for events. The SDK is plain TypeScript with no native module, so there is nothing to link or prebuild. Remote push on Android needs a development build from Expo SDK 53.

### What happens without AsyncStorage?

The SDK works in memory only. The user id is lost at each app restart, and events captured right before the app is killed are lost. Install @react-native-async-storage/async-storage or pass your own storage adapter.

### Which push libraries does the React Native SDK work with?

Any library that gives you a token. The SDK bundles none. Pass an Expo push token, an FCM token, a raw APNs token or a OneSignal subscription id to setPushToken().

### Are events tracked before login lost?

No. Before identify() the SDK sends events under a saved random anonymous id. The next identify() carries the same id, and Whisperr moves those events to the user.

Source: https://docs.whisperr.net/sdks/react-native/
