Skip to content

Expo SDK

Last updated

@whisperr/expo adds push notifications to an Expo app on Whisperr. It builds on @whisperr/react-native, which sends identify calls and events. Use this page to install both, identify users with their email address, track events, and set up push.

@whisperr/expo has three parts:

  • A config plugin. It sets up native push at prebuild: the iOS push entitlement, and the Android notification icon, color and default channel.
  • registerForPushNotifications(). It asks for permission, gets the Expo push token and sends both to Whisperr. It keeps them current.
  • useWhisperrNotificationResponse(). It records push_opened when the user taps a Whisperr notification and gives you the deep link.

Using a coding agent? Connect it to the Whisperr MCP server. It installs the SDK and wires your events and push for you.

npx expo install @whisperr/expo @whisperr/react-native expo-notifications expo-constants expo-application @react-native-async-storage/async-storage

The packages are @whisperr/expo and @whisperr/react-native on npm. @whisperr/expo needs Expo SDK 52 or later and @whisperr/react-native 0.5.0 or later.

Add the plugin to your app config. With app.json, npx expo install may already have added "@whisperr/expo" to plugins: add the options to that entry. With app.config.js or app.config.ts, add it yourself:

app.json
{
"expo": {
"plugins": [
["@whisperr/expo", { "icon": "./assets/notification-icon.png", "color": "#4F6BFF" }]
],
"android": { "googleServicesFile": "./google-services.json" }
}
}

Then make a new build (eas build, or npx expo run:ios and npx expo run:android). A config plugin changes native code, so a JavaScript-only update is not enough.

At prebuild, the plugin warns when push cannot work: no android.googleServicesFile, or no extra.eas.projectId (run eas init).

OptionPlatformDefaultWhat it does
modeiOSdevelopmentThe aps-environment entitlement. App Store and TestFlight builds are re-signed for production.
iconAndroidnoneSmall notification icon: a 96×96 all-white PNG with transparency.
colorAndroidnoneIcon tint.
androidChannelIdAndroiddefaultThe channel FCM uses when a message names none. registerForPushNotifications() creates this channel.
soundsbothnoneCustom notification sounds.
enableBackgroundRemoteNotificationsiOSfalseAdds remote-notification to UIBackgroundModes (silent pushes).

The plugin applies the expo-notifications plugin for you. If your app config already lists expo-notifications, your entry and its options win.

Create an App key (wpk_…, the publishable key) in the Whisperr dashboard under Settings → API keys. During setup you can also add one on Home → Connect your code → Copy prompt with Add an app key. After setup, the prompt is in Integrations → Your code → Your app → Copy a prompt, where Get an app key opens the API keys page.

The app key ships in your app bundle by design. Never put a server key (wrk_…) in app code.

app/_layout.tsx
import { Whisperr } from "@whisperr/react-native";
// Uses AsyncStorage automatically when it is installed.
const whisperr = Whisperr.init({ apiKey: "wpk_your_publishable_key" });

Call identify after sign-up, after login, and on every app start when a user is signed in. Send the email address as a channel, never as a trait:

whisperr.identify(user.id, {
traits: { first_name: user.firstName },
channels: [{ type: "email", address: user.email, optedIn: user.agreedToEmail }],
});

App-side consent needs @whisperr/react-native 0.6.0 or later. Older versions cannot set it: Whisperr stores the address as pending.

The email and emailOptedIn shortcut sends the same channel: whisperr.identify(user.id, { email: user.email, emailOptedIn: user.agreedToEmail }). Leave emailOptedIn out when the app does not know.

It depends on your answer to Does your signup get permission to email users?, which Whisperr asks during setup:

AnswerAn email address the app sends
YesIs opted in, unless the app sends optedIn: false.
Per userIs opted in only when the app sends optedIn: true.
No, or not answeredIs stored as pending. Your backend confirms it with the server key.

When Require a server key is on, only your server can opt an address in, whatever the answer. Some limits protect the publishable key, which anyone can read from your app. For example, the app cannot change a user’s existing address. See Contact consent for the full rules.

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.

On logout, call whisperr.reset().

// An example name. Use your app's own action.
whisperr.track("recipe_saved", { source: "search" });

Event names must be lowercase snake_case. Do not send personal data, such as an email address or a name, in event properties. track() works before identify(): the next identify() moves those events to the user. See Event design and the React Native SDK for screens, automatic events and options.

Connect the Expo Push provider in the Whisperr dashboard first. See Connect a push provider.

import { registerForPushNotifications } from "@whisperr/expo";
// After identify, at the moment you want the OS prompt:
const { status, token, reason } = await registerForPushNotifications();

It does this:

  1. On Android, it creates the notification channel. Android 13 and later show the prompt only when a channel exists.
  2. It reads the permission and, if the app may still ask, shows the prompt.
  3. It reports the permission with whisperr.setPushPermission(status).
  4. If notifications are allowed, it gets the Expo push token and calls whisperr.setPushToken() with kind: "expo", the EAS project id and the app id. Whisperr uses the two ids to catch a token from the wrong project or app before a send.
  5. It keeps watching. Each time the app comes to the foreground it checks the permission again, and it gets a new token when the device token changes. stopPushNotificationUpdates() stops this.

It never throws. When there is no token, reason says why:

reasonMeaning
permission_deniedThe user turned notifications off.
permission_undeterminedThe app did not ask (requestPermission: false) or the user did not answer.
expo_go_androidExpo Go on Android cannot receive remote push (Expo SDK 53 and later). Use a development build.
missing_project_idNo EAS project id. Run eas init or pass projectId.
token_unavailableThe OS gave no token, often on a simulator or emulator. See error.
not_initializedWhisperr.init() did not run and you passed no client.

Options: client, projectId, tokenType ("expo" by default, or "device"), providerProject, requestPermission (default true), provisional (quiet iOS delivery without a prompt), ios (prompt options), androidChannel ({ id, name, importance } or false), watch (default true) and debug.

With tokenType: "device", the SDK sends the native token instead: an APNs token on iOS, an FCM token on Android. Use it when you send through your own APNs key or Firebase project, and pass your Firebase project id as providerProject.

Whisperr sends Expo tokens through Expo’s push service. Expo needs:

  • iOS: an APNs key. eas build sets it up, or run eas credentials.
  • Android: an FCM V1 service account key, uploaded with eas credentials, and google-services.json in the app config.

See Expo: push notifications setup.

app/_layout.tsx
import { router, Stack } from "expo-router";
import { useEffect } from "react";
import { deepLinkToHref, useWhisperrNotificationResponse } from "@whisperr/expo";
export default function RootLayout() {
const open = useWhisperrNotificationResponse();
useEffect(() => {
const href = open?.deepLink ? deepLinkToHref(open.deepLink, "myapp") : null;
if (href) router.push(href);
}, [open]);
return <Stack />;
}
  • It sends push_opened once per message, also for the tap that launched the app. Taps on notifications from other senders are ignored.
  • It also sends push_received once per message for Whisperr notifications that arrive while the app is in the foreground. Pass trackReceived: false to turn this off.
  • deepLinkToHref(link, scheme?) turns myapp://offers/annual or https://example.com/offers/annual into /offers/annual. With scheme, it accepts only links with your app’s scheme.

Outside React, use subscribeToWhisperrNotificationOpens(onOpen) and subscribeToWhisperrNotificationReceipts(). Each returns an unsubscribe function.

With @whisperr/react-native 0.7.0 or later, the tap that opens the app also sets launch_source: "push" (and whisperr_message_id) on that launch’s app_opened. See Launch source.

From @whisperr/expo 0.2.0:

app/_layout.tsx
import { Stack } from "expo-router";
import { useWhisperrScreens } from "@whisperr/expo";
export default function RootLayout() {
useWhisperrScreens();
return <Stack />;
}

It sends screen_viewed each time the route changes, with the route template as screen_name: app/(tabs)/lesson/[id].tsx is /lesson/[id], never /lesson/42. Groups such as (tabs) are left out, and a repeat of the same template is skipped. expo-router is an optional peer: without it the hook does nothing. For React Navigation, use whisperrNavigationTracker() from @whisperr/react-native.

  • Events work in Expo Go.
  • Remote push does not work in Expo Go on Android (Expo SDK 53 and later). Use a development build.
  • Simulators and emulators often give no push token. Test push on a real device.

Common questions

Do I need @whisperr/expo, or is @whisperr/react-native enough?

@whisperr/react-native sends identify calls and events, also in Expo. @whisperr/expo adds push for Expo apps: a config plugin, registerForPushNotifications() and a hook for notification taps. Install both when you want push.

Can an Expo app without a backend email its users?

Yes, when you told Whisperr that your signup gets permission to email users, and the app uses @whisperr/react-native 0.6.0 or later. Then an email address that the app sends with identify can get messages. See Contact consent.

Does push work in Expo Go?

Events work in Expo Go. Remote push does not work in Expo Go on Android from Expo SDK 53. Use a development build.