@whisperr/react-native
v0.2.1
Published
Whisperr React Native SDK — reliable, Expo-friendly churn-signal event tracking with zero native code.
Maintainers
Readme
@whisperr/react-native
Reliable, Expo-friendly churn-signal event tracking for React Native — zero native code, works in Expo Go, bare React Native, and dev clients alike.
npm i @whisperr/react-nativeimport AsyncStorage from "@react-native-async-storage/async-storage";
import { Whisperr } from "@whisperr/react-native";
const whisperr = Whisperr.init({ apiKey: "wrk_…", storage: AsyncStorage });
// after the user logs in / on session restore
whisperr.identify("user_123", { email: "[email protected]", traits: { plan: "pro" } });
// when something happens
whisperr.track("subscription_cancelled", { reason: "too_expensive" });
// on logout
whisperr.reset();- Pure TypeScript, zero dependencies, zero native modules — nothing to link, nothing to prebuild; fully compatible with Expo Go.
- Anonymous → identified — events before login buffer on-device and
attribute to the user on
identify(). - Never loses events — durable on-device queue (via your storage adapter),
automatic flush when the app backgrounds, batching, retry/backoff, and a
stable
$message_idper event so the backend dedups at-least-once retries. - Consent-friendly —
optIn()/optOut()persist across launches.
Storage (durability)
The SDK never imports a native module itself — you hand it any
AsyncStorage-compatible adapter (getItem/setItem/removeItem):
// The common case:
import AsyncStorage from "@react-native-async-storage/async-storage";
Whisperr.init({ apiKey: "wrk_…", storage: AsyncStorage });
// Or MMKV, expo-sqlite/kv-store, SecureStore — anything with the same shape.Without storage the SDK still works, but the queue is memory-only: events
captured right before a crash or app kill are lost. In Expo, install the
adapter with npx expo install @react-native-async-storage/async-storage.
React bindings
import { WhisperrProvider, useWhisperr } from "@whisperr/react-native";
export default function App() {
return (
<WhisperrProvider options={{ apiKey: "wrk_…", storage: AsyncStorage }}>
<Root />
</WhisperrProvider>
);
}
function CancelButton() {
const whisperr = useWhisperr();
return <Button onPress={() => whisperr.track("cancel_tapped")} title="Cancel" />;
}Screens
whisperr.screen("Paywall", { plan: "pro" }); // tracks screen_viewedWire it to your navigator once, e.g. React Navigation:
<NavigationContainer
onStateChange={() => whisperr.screen(navigationRef.getCurrentRoute()?.name)}
>Identify
whisperr.identify("user_123", {
traits: { name: "Ada", plan: "pro" },
email: "[email protected]",
phone: "+15551234567",
pushToken: expoPushToken, // expands to an opted-in push channel
});
// Full channel control (consent / verification):
whisperr.identify("user_123", {
channels: [
{ type: "email", address: "[email protected]", verified: true },
{ type: "sms", address: "+15551234567", optedIn: false },
],
});Push notifications
The SDK never bundles a push library — hand it the token your own messaging
setup produces and Whisperr keeps the push channel current:
whisperr.setPushToken(token);- Called after login, it re-identifies the push channel immediately.
- Called before login, the token is buffered and attached to the next
identify(). - Token rotation is handled: the previously sent token is opted out and the new one opted in, so stale tokens don't accumulate — and tokens from the user's other devices are never touched.
- Setting the same token twice is a no-op, so it's safe to call on every
launch and from
onTokenRefresh. The last-sent (user, token) pair is persisted through your storage adapter, so the no-op holds across app restarts — and a rotation that happens after a relaunch still opts out the stale token. - After
reset()(logout), callsetPushTokenagain once the next user logs in.
With @react-native-firebase/messaging:
import messaging from "@react-native-firebase/messaging";
import { useWhisperrPushToken } from "@whisperr/react-native";
function PushBridge() {
const [token, setToken] = useState<string | null>(null);
useEffect(() => {
messaging().getToken().then(setToken);
return messaging().onTokenRefresh(setToken);
}, []);
useWhisperrPushToken(token); // forwards to whisperr.setPushToken()
return null;
}With expo-notifications:
import * as Notifications from "expo-notifications";
const [token, setToken] = useState<string | null>(null);
useEffect(() => {
Notifications.getDevicePushTokenAsync().then((t) => setToken(t.data));
const sub = Notifications.addPushTokenListener((t) => setToken(t.data));
return () => sub.remove();
}, []);
useWhisperrPushToken(token);Delivery
- Events send to
POST /v1/events/batch; identity toPOST /v1/identify, authenticated withX-API-Key(the ingestion key is publishable). - Event names must be lowercase
snake_case; invalid names are dropped before queueing and surfaced throughonError. 401/403pause delivery and retain the queue (auth);429/5xx/network errors retry with backoff, then retain (retry_exhausted); other4xxdrop the offending batch (dropped).
Options
Whisperr.init({
apiKey: "wrk_…",
storage: AsyncStorage, // durable queue + identity (recommended)
flushAt: 20, // flush when this many events are queued
flushIntervalMs: 10000, // periodic flush
flushOnAppBackground: true, // flush when the app leaves the foreground
maxQueueSize: 1000, // oldest events drop past this
maxRetries: 6,
onError: (e) => console.warn("whisperr:", e.type, e.message),
});Whisperr.init() is an idempotent singleton; construct WhisperrClient
directly for explicit lifetimes (call close() when done).
Development
The test suite consumes the shared whisperr-spec fixtures:
WHISPERR_SPEC_PATH=../whisperr-spec/conformance/wire.json npm testWhisperr — predict churn, automate interventions, recover revenue.
