# Expo push notifications in production: the setup checklist that prevents silent failures

> Expo push tokens, FCM/APNs credentials, and send logic done right — a production checklist for AI-built React Native apps.
> By Dave · 2026-09-04
> Source: https://otf-kit.dev/blog/expo-push-notifications-production

AI-built React Native apps usually reach TestFlight with push notifications half-wired: the token code runs on a simulator where it can never work, the server key is still the legacy FCM credential Google is retiring, and nobody tested what happens when the user taps the notification. Then launch week arrives and pushes silently fail for half your users.

This post is the production checklist that prevents that. It covers how Expo push tokens actually work, the exact credentials you need on both platforms before your first production build, the token-registration code, the backend send logic, foreground and tap handling, and the four silent failure modes that eat launches. It follows our earlier production guides on [OTA updates](/blog/react-native-ota-updates-production), the [app store submission checklist](/blog/app-store-submission-checklist-ai-built-app), and the [ship-to-production checklist](/blog/ship-ai-mvp-to-production-checklist) — read those first if you have not shipped yet.

## How expo push tokens actually work

Expo simplifies push by sitting between your app and the two platform services: Firebase Cloud Messaging (FCM) on Android and Apple Push Notification Service (APNs) on iOS. Your app asks Expo's servers for an Expo push token, your backend sends the message to the Expo Push Service, and Expo routes it to FCM or APNs on your behalf. You treat Android and iOS the same way in both your client code and your backend.

Three consequences builders miss. First, the Expo push token is not the native device token — it is an Expo-issued identifier that only works when sent through the Expo Push Service. Second, push tokens can change, so your registration code must run on every app start and your backend must update the stored token when it does. Third, none of this works on an iOS simulator or Android emulator without Google Play services — tokens must be tested on physical devices, which is exactly the step AI-generated code never reminds you to do.

## Credentials first: FCM v1 and APNs before your first build

Push credentials are a build-time concern, not a runtime one. If you ship a production build without them configured, no client-side code will save you — you will need a full rebuild, and on iOS that means another trip through App Store review.

On Android, you need a Firebase project with a service-account key using the FCM v1 API. Google has been moving developers off the legacy server key, so generate the service-account credential in the Firebase console and upload it to your Expo project via EAS credentials. On iOS, you need an APNs key (a .p8 file) from your Apple Developer account, scoped to push notifications, plus your Team ID and Key ID registered with EAS. The Expo setup guide walks through both in order: configure Firebase for FCM v1 on Android, set up Android and iOS credentials on EAS, build with EAS Build, and test with the Expo Notifications tool before you write a single line of send logic.

The checklist version: Firebase service-account key uploaded, APNs .p8 key plus Team ID and Key ID registered, EAS production build completed with those credentials, push token retrieved on a physical device for each platform. If any box is unchecked, stop and fix it — everything below depends on it.

## Registering the token in your app

Token registration belongs in your app startup path, guarded so it only runs on physical devices. The pattern below requests permission, fetches the Expo push token, and posts it to your backend with the user identity attached. Run it on every launch, not just first install, so rotated tokens stay current.

```tsx
import * as Device from 'expo-device';
import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';

async function registerPushToken(userId: string) {
  if (!Device.isDevice) return; // simulators can never receive pushes
  const { status: existing } = await Notifications.getPermissionsAsync();
  const { status } =
    existing === 'granted'
      ? { status: existing }
      : await Notifications.requestPermissionsAsync();
  if (status !== 'granted') return;
  const { data: expoPushToken } = await Notifications.getExpoPushTokenAsync({
    projectId: 'your-expo-project-id',
  });
  await fetch('https://api.yourapp.com/devices', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      userId,
      expoPushToken,
      platform: Platform.OS,
    }),
  });
}
```

Two details AI-generated code routinely gets wrong. The `projectId` must be your EAS project ID, not your slug or app name — a wrong ID produces a token that looks valid and fails at send time. And the Android notification channel must be configured before any notification arrives, or Android 8+ devices will drop foreground notifications silently. Set a default channel at startup alongside registration.

## Sending from your backend

Sending goes through the Expo Push Service REST API: your server POSTs a message batch addressed to Expo push tokens, and Expo fans it out to FCM and APNs. Keep this logic server-side — embedding your send path in client code leaks your ability to throttle, and Apple will reject apps that spam the notification path.

```typescript
type PushMessage = {
  to: string; // Expo push token, e.g. ExponentPushToken[xxxx]
  sound: 'default';
  title: string;
  body: string;
  data?: Record<string, unknown>;
};

async function sendPush(messages: PushMessage[]) {
  const res = await fetch('https://exp.host/--/api/v2/push/send', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Accept: 'application/json',
    },
    body: JSON.stringify(messages),
  });
  const tickets = await res.json();
  // Each ticket is per-message: 'ok' or an error with details.
  // Persist tickets, then query receipts for confirmed delivery failures.
  return tickets.data;
}
```

The two-phase model matters: the send call returns tickets immediately, and tickets only confirm receipt by Expo — not delivery to the device. For anything load-bearing (order updates, security alerts), query the receipt endpoint afterwards and handle `DeviceNotRegistered` by deleting the stale token from your database. Stale tokens accumulate fast when users reinstall, and a database full of dead tokens is the most common cause of mysteriously degrading delivery rates months after launch.

## Handling notifications in the foreground and on tap

By default, a notification that arrives while the app is foregrounded does nothing visible on either platform. You must set a foreground handler that decides what to show — typically an alert or banner plus the sound. Separately, register a response listener that fires when the user taps the notification, and route by the `data` payload you attached at send time: order ID opens the order screen, message ID opens the thread.

```tsx
Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowBanner: true,
    shouldShowList: true,
    shouldPlaySound: true,
    shouldSetBadge: true,
  }),
});

const sub = Notifications.addNotificationResponseReceivedListener(
  (response) => {
    const { orderId } = response.notification.request.content.data ?? {};
    if (orderId) router.push(`/orders/${orderId}`);
  },
);
```

Test both paths on a physical device with the app killed, backgrounded, and foregrounded. AI-built apps almost always test only the foregrounded case, and the killed-app tap path is where misconfigured `data` payloads and missing deep-link routes surface — the same deep-link discipline our submission checklist covers.

## The four silent failure modes

First, simulator-tested tokens. Everything passed in development because the registration code was never exercised on hardware. Fix: physical-device token retrieval is a release gate, not a nice-to-have.

Second, stale credentials after a key rotation. Someone regenerates the APNs key or the Firebase service account and forgets EAS. Pushes stop with no client-side symptom. Fix: credential changes trigger a rebuild and a token round-trip test the same day.

Third, dead-token buildup. Delivery rates decay over months as tokens go stale. Fix: process `DeviceNotRegistered` receipts on a schedule and prune.

Fourth, missing Android channels. Foreground notifications vanish on Android 8+ with no error anywhere. Fix: set the default channel at startup, before the first notification can arrive.

Work through this list the week before submission, not the week after. Push is infrastructure: boring when it works, a launch-killer when it does not.

## Sources

- Expo push notifications overview, setup, and sending guides — [Expo documentation](https://docs.expo.dev/push-notifications/overview/)
- React Native error and crash reporting setup — [Sentry React Native docs](https://docs.sentry.io/platforms/react-native/)
- Production templates and starter kits referenced above — [OTF templates](https://otf-kit.dev/templates)