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.
Install
Section titled “Install”npm i @whisperr/react-native @react-native-async-storage/async-storageIn 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.
Quickstart
Section titled “Quickstart”Create an App key (wpk_…, the publishable key) in the Whisperr dashboard
under Settings → API keys.
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
Section titled “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-storageis installed, the SDK uses it. You pass nothing. - Your own adapter: pass any object with
getItem,setItemandremoveItem(for example MMKV orexpo-sqlite/kv-store) asstorage. 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 });Identify
Section titled “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:
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.
Restore identify at app start
Section titled “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
Section titled “App language”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 workswhisperr.setAppLanguage(null); // clear the stored valueThe 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.
What happens before login?
Section titled “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
Section titled “Screens”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.
Provider and hook
Section titled “Provider and hook”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
Section titled “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 |
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
Section titled “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_errors (error_count, including the ones the limits below drop).
foreground_ms uses a monotonic clock.
Launch source
Section titled “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/expothis is automatic). Addswhisperr_message_id. - Links: the SDK reads
Linking.getInitialURL()and listens forLinkingurlevents itself. Anhttpslink is auniversal_link(withlink_host), any other scheme adeep_link. A link withwsrc=widgetorwsrc=shortcutin its query is awidgetorshortcutopen, so give your widget and quick-action URLs that parameter (for examplemyapp://home?wsrc=widget). A link withwmid=<message id>is anemailopen. Links addlink_route, the path with the query and fragment dropped and id-like segments (digits, UUIDs, 16+ characters) replaced by:id, andutm_source,utm_mediumandutm_campaignwhen the link has them. first_after_update: truemarks the first open afterapp_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
Section titled “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():
try { await loadCart();} catch (error) { whisperr.trackError(error, { screen: "Cart" }); // error_kind: "handled"}whisperr.trackError({ name: "HTTP_503" }, { kind: "network" }); // a failed requestkind 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.
Push notifications
Section titled “Push notifications”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.
Token kinds
Section titled “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" }) |
pushEnvis the APNs environment:sandboxfor development-signed builds,productionfor 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 (clientAppIdoption, elseexpo-applicationorreact-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
Section titled “Token lifecycle”setPushTokenis 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 callsetPushTokenagain.- 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
Section titled “Push permission”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 EPHEMERALfunction 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);}, []);Push opens
Section titled “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
Section titled “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.
messaging().onMessage((message) => whisperr.trackPushReceived(message));Opt-out
Section titled “Opt-out”whisperr.optOut(); // stops all capture, automatic events includedwhisperr.optIn(); // sends againThe 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 toPOST /v1/identify. - Each event carries a stable
$message_id, so a resend after a restart does not create a duplicate. 401and403pause delivery and keep the queue (auth).429,5xxand network errors retry with backoff, then keep the queue (retry_exhausted). Other4xxdrop the batch (dropped). Every drop reachesonError.- A
Retry-Afteron429or503sets the wait, up to 60 seconds. - The queue flushes when the app goes to the background.
See the delivery contract.
Options
Section titled “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
Section titled “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.