Skip to content

React Native SDK

Last updated

@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. 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. It installs the SDK and wires your events and push for you.

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. It needs React 17 or later and React Native 0.65 or later. Release notes are in the changelog.

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

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

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.
Whisperr.init({ apiKey: "wpk_your_publishable_key", storage: myMmkvAdapter });

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:

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.

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 for the full rules and the limits on app-set addresses. Send push tokens with setPushToken, 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.

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.

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

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.

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

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.

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.

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:

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() from @whisperr/expo in your root layout instead. Screen names should be route names, never titles that contain user data.

App.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" />;
}

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

EventSent whenExtra properties
app_installedFirst launch of a fresh installnone
app_updatedFirst launch after the app version or build changedprevious_version, previous_build
app_openedThe app comes to the foregroundcold_start, launch_source and the launch details
app_backgroundedThe app goes to the backgroundforeground_ms, session_id, screen_count, unique_screen_count, event_count, error_count
app_errorAn uncaught JS error, or a trackError() callerror_kind, fatal, error_name, fingerprint, screen_name, session_id
app_crashedThe first launch after the app ended in the foregroundcrash_kind, detected_by, crashed_app_version, crashed_session_id, crashed_screen_name, foreground
push_receivedYou call trackPushReceived() for a Whisperr pushwhisperr_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.

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_errors (error_count, including the ones the limits below drop). foreground_ms uses a monotonic clock.

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

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

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.

Whisperr sends push through your own provider account. Connect it in the dashboard first: see Connect a push provider. Then hand the SDK the token your messaging setup produces. The SDK never bundles a push library. In an Expo app, @whisperr/expo gets the token and the permission for you.

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

TokenCall
Expo push token (ExponentPushToken[…])setPushToken({ token }), detected as expo
expo-notifications token objectsetPushToken(await Notifications.getExpoPushTokenAsync({ projectId })) sends expo. getDevicePushTokenAsync() sends apns on iOS and fcm on Android.
FCM registration tokensetPushToken({ token, kind: "fcm" })
Raw APNs device token (hex)setPushToken({ token, kind: "apns", pushEnv: "production" })
OneSignal subscription idsetPushToken({ 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.
  • 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.

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

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.

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:

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

How do I set up push with expo-notifications?

Section titled “How do I set up push with expo-notifications?”

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

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:

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?

Section titled “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.

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:

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);
}, []);

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.

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

messaging().onMessage((message) => whisperr.trackPushReceived(message));
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?

Section titled “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.

OptionDefaultNotes
apiKeynonePublishable key (wpk_…). Required.
storageAsyncStorage, when installedgetItem / setItem / removeItem adapter for the durable queue.
baseUrlhttps://api.whisperr.netIngestion base URL.
flushAt / flushIntervalMs20 / 10000Batch triggers.
flushOnAppBackgroundtrueFlush when the app leaves the foreground.
trackAppLifecycleEventstrueSend the automatic app events.
appVersion / appBuildnoneApp version and build for the automatic events.
captureErrorstrueFrom 0.7.0. app_error from uncaught JS errors.
captureUnhandledRejectionsfalseFrom 0.7.0. app_error from unhandled promise rejections (release builds on Hermes). Replaces another tool’s Hermes rejection tracker.
launchSourceWindowMs1500From 0.7.0. How long app_opened waits for its launch source.
maxQueueSize1000Oldest events drop beyond this.
maxBatchSize500Events per batch request. 500 is the backend cap.
maxRetries6Retries before a batch is kept for the next flush.
requestTimeoutMs10000Timeout per request.
clientAppIdnoneBundle id or package name sent with push tokens.
disabledfalseTurns the client into a no-op.
debugfalseVerbose logging.
onErrornoneDelivery failures (auth / dropped / retry_exhausted).

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.