# Expo SDK

> Install @whisperr/expo with @whisperr/react-native to identify users, send their email with consent, track events and set up push notifications in an Expo app.

`@whisperr/expo` adds push notifications to an Expo app on Whisperr. It builds on [`@whisperr/react-native`](/sdks/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](/agents/). It installs the SDK and wires your events and push for you.

## Install

```bash
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`](https://www.npmjs.com/package/@whisperr/expo) and [`@whisperr/react-native`](https://www.npmjs.com/package/@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`

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

### Plugin options

| Option                                | Platform | Default       | What it does                                                                                           |
| ------------------------------------- | -------- | ------------- | ------------------------------------------------------------------------------------------------------ |
| `mode`                                | iOS      | `development` | The `aps-environment` entitlement. App Store and TestFlight builds are re-signed for production.       |
| `icon`                                | Android  | none          | Small notification icon: a 96×96 all-white PNG with transparency.                                      |
| `color`                               | Android  | none          | Icon tint.                                                                                             |
| `androidChannelId`                    | Android  | `default`     | The channel FCM uses when a message names none. `registerForPushNotifications()` creates this channel. |
| `sounds`                              | both     | none          | Custom notification sounds.                                                                            |
| `enableBackgroundRemoteNotifications` | iOS      | `false`       | Adds `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.

## Get an app key

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.

## Initialize and identify

`app/_layout.tsx`

```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:

```ts
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.

### Can the app opt the user in to email?

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

| Answer                  | An email address the app sends                                      |
| ----------------------- | ------------------------------------------------------------------- |
| **Yes**                 | Is opted in, unless the app sends `optedIn: false`.                 |
| **Per user**            | Is opted in only when the app sends `optedIn: true`.                |
| **No**, or not answered | Is 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](/concepts/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()`.

## Track events

```ts
// 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](/concepts/events/) and the [React Native SDK](/sdks/react-native/#track) for screens, automatic events and options.

## Register for push

Connect the **Expo Push** provider in the Whisperr dashboard first. See [Connect a push provider](/integrations/push-providers/).

```ts
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:

| `reason`                  | Meaning                                                                                         |
| ------------------------- | ----------------------------------------------------------------------------------------------- |
| `permission_denied`       | The user turned notifications off.                                                              |
| `permission_undetermined` | The app did not ask (`requestPermission: false`) or the user did not answer.                    |
| `expo_go_android`         | Expo Go on Android cannot receive remote push (Expo SDK 53 and later). Use a development build. |
| `missing_project_id`      | No EAS project id. Run `eas init` or pass `projectId`.                                          |
| `token_unavailable`       | The OS gave no token, often on a simulator or emulator. See `error`.                            |
| `not_initialized`         | `Whisperr.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`.

### Credentials

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](https://docs.expo.dev/push-notifications/push-notifications-setup/).

## Notification taps and deep links

`app/_layout.tsx`

```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](/sdks/react-native/#launch-source).

## Screens with expo-router

From `@whisperr/expo` 0.2.0:

`app/_layout.tsx`

```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()`](/sdks/react-native/#screens) from `@whisperr/react-native`.

## Expo Go

- Events work in Expo Go.
- Remote push does not work in Expo Go on Android (Expo SDK 53 and later). Use a [development build](https://docs.expo.dev/develop/development-builds/introduction/).
- 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.

Source: https://docs.whisperr.net/sdks/expo/
