Developers · API v1.0.0

Expo Integration

This guide is for apps built with Expo (managed workflow or development builds). For the general API reference, see the Overview.

Supported versions: Expo SDK 50+ (recommended: latest SDK) · iOS 13.4+ · Android 7.0+ (API 24)

Remote push notifications don't work in Expo Go (support was removed on Android as of SDK 53). For testing, use a development build or a build produced with EAS Build. Push notifications must be tested on a physical device, not an emulator/simulator.

1. Installation

Terminal
npx expo install expo-notifications expo-device expo-constants expo-secure-store expo-localization expo-crypto

app.json / app.config.js:

JSON
{
  "expo": {
    "plugins": [
      ["expo-notifications", { "defaultChannel": "default" }]
    ],
    "extra": {
      "eas": { "projectId": "YOUR-EAS-PROJECT-ID" }
    }
  }
}

Upload your push credentials to your Expo project (one time):

Terminal
eas credentials   # Android: FCM V1 service account JSON, iOS: APNs key

2. NotiPilot client

src/notipilot.ts:

TypeScript
import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import * as SecureStore from 'expo-secure-store';
import * as Localization from 'expo-localization';
import * as Crypto from 'expo-crypto';
import Constants from 'expo-constants';
import { Platform } from 'react-native';

const NOTIPILOT_BASE_URL = 'https://app.notipilot.com/api/v1';

// Must match the "Expo Project ID" in the NotiPilot dashboard
const PROJECT_ID: string =
  Constants.expoConfig?.extra?.eas?.projectId ?? Constants.easConfig?.projectId;

const DEVICE_UID_KEY = 'notipilot_device_uid';

// On SDK 52 and earlier, use shouldShowAlert: true instead of shouldShowBanner/shouldShowList
Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowBanner: true,
    shouldShowList: true,
    shouldPlaySound: true,
    shouldSetBadge: false,
  }),
});

async function getDeviceUid(): Promise<string> {
  let uid = await SecureStore.getItemAsync(DEVICE_UID_KEY);
  if (!uid) {
    uid = Crypto.randomUUID();
    await SecureStore.setItemAsync(DEVICE_UID_KEY, uid);
  }
  return uid;
}

async function post(path: string, body: object, attempt = 0): Promise<any> {
  const res = await fetch(`${NOTIPILOT_BASE_URL}${path}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
    body: JSON.stringify(body),
  });
  const json = await res.json().catch(() => ({}));

  if ((res.status === 429 || res.status >= 500) && attempt < 3) {
    const waitSec = Number(json.retry_after) || 2 ** attempt;
    await new Promise((r) => setTimeout(r, waitSec * 1000));
    return post(path, body, attempt + 1);
  }
  if (!res.ok) {
    console.warn('[NotiPilot]', res.status, json.error, json.errors ?? json.message);
  }
  return json;
}

async function getExpoPushToken(): Promise<string | null> {
  if (!Device.isDevice) return null;

  if (Platform.OS === 'android') {
    await Notifications.setNotificationChannelAsync('default', {
      name: 'General',
      importance: Notifications.AndroidImportance.HIGH,
    });
  }

  let { status } = await Notifications.getPermissionsAsync();
  if (status !== 'granted') {
    ({ status } = await Notifications.requestPermissionsAsync());
  }
  if (status !== 'granted') return null;

  const { data } = await Notifications.getExpoPushTokenAsync({ projectId: PROJECT_ID });
  return data; // "ExponentPushToken[...]"
}

export type NotiPilotAttributes = Record<string, string | number | boolean | null>;

/** Call on every app launch. */
export async function registerDevice(extra: NotiPilotAttributes = {}) {
  const token = await getExpoPushToken();
  if (!token) return null;

  const locale = Localization.getLocales()[0];

  return post('/register-device', {
    app_id: PROJECT_ID,
    device_uid: await getDeviceUid(),
    token,
    platform: Platform.OS, // 'ios' | 'android'
    provider: 'expo',
    attributes: {
      locale: locale?.languageCode ?? null,   // "tr"
      country: locale?.regionCode ?? null,    // "TR"
      app_version: Constants.expoConfig?.version ?? null,
      ...extra,                               // e.g. { city: 'Istanbul' }
    },
  });
}

/** Call when the user signs in. */
export async function identify(externalId: string, attributes?: NotiPilotAttributes) {
  return post('/identify-device', {
    app_id: PROJECT_ID,
    device_uid: await getDeviceUid(),
    external_id: externalId,
    ...(attributes ? { attributes } : {}),
  });
}

/** Call when the user signs out. */
export async function logout() {
  const token = await getExpoPushToken();
  if (!token) return null;
  return post('/register-device', {
    app_id: PROJECT_ID,
    device_uid: await getDeviceUid(),
    token,
    platform: Platform.OS,
    external_id: null,
  });
}

/** Keeps NotiPilot up to date when the token is refreshed. Call once at app startup. */
export function listenForTokenChanges() {
  return Notifications.addPushTokenListener(() => {
    registerDevice().catch(() => {});
  });
}

3. Usage

TSX
import { useEffect } from 'react';
import * as Notifications from 'expo-notifications';
import { registerDevice, listenForTokenChanges, identify } from './src/notipilot';

export default function App() {
  useEffect(() => {
    registerDevice({ city: 'Istanbul' }).catch(console.warn);
    const tokenSub = listenForTokenChanges();

    // When a notification is tapped, read the `data` field sent from the dashboard
    const tapSub = Notifications.addNotificationResponseReceivedListener((response) => {
      const data = response.notification.request.content.data;
      // e.g. data.screen === 'product' → navigation.navigate('Product', { id: data.product_id })
    });

    return () => {
      tokenSub.remove();
      tapSub.remove();
    };
  }, []);

  // After sign-in: await identify(user.id, { gender: user.gender });
  return null;
}

4. Checklist

  • Expo Project ID in the dashboard = extra.eas.projectId
  • FCM V1 and APNs credentials uploaded with eas credentials
  • Tested on a physical device with a development/production build
  • country and, if possible, city are being sent (for location segments)
  • identify is called after sign-in and logout on sign-out

Common issues

Symptom Solution
404 unknown_app The app_id is not registered in the dashboard or is incorrect. Check the Expo Project ID in the dashboard.
Can't get a token Use a physical device, check notification permission, and make sure you pass the projectId parameter.
Registration succeeds but no notifications arrive The FCM V1 / APNs credentials may be missing from the Expo project. Send a test notification to the token with the Expo push tool.
Notifications arrive silently on Android Create a notification channel with the ID default and HIGH importance.