All articles
Mobile DevelopmentMay 16, 2026 6 min read

Push Notifications in Expo: Why FCM v1 Broke Your Pipeline and How to Fix It

Google's legacy FCM API is gone, and a lot of Expo apps quietly stopped delivering pushes on Android. Here's what actually changed, what to check, and how to wire it up properly in 2026.

Push Notifications in Expo: Why FCM v1 Broke Your Pipeline and How to Fix It

If your Expo app's Android push notifications quietly stopped working in the last year and you can't figure out why, the answer is almost always the same: you're still pointing at the legacy FCM API, and Google turned it off. Apple side keeps humming along, your test devices on iOS look fine, and nobody on the team notices until a product manager asks why Android open rates fell off a cliff.

This is the migration write-up I wish we'd had when we hit it on a client project. It's specific to Expo's managed push service, but the moving parts apply if you're using expo-notifications with a bare workflow too.

What actually changed with FCM

Google deprecated the legacy HTTP and XMPP FCM APIs and moved everything to FCM HTTP v1. The old API used a server key — a single long string you pasted into the Expo dashboard and forgot about. The new API uses a service account JSON with OAuth2, scoped credentials, and proper IAM.

Functionally, you still send a message and it lands on a device. Operationally, three things are different:

  • Authentication is OAuth2 with short-lived tokens, not a static server key.
  • The payload schema is stricter and nested under a message object.
  • Server keys generated before mid-2024 stopped working entirely once the sunset window closed.

If your Expo project predates this and nobody touched push config, your Android notifications are either silently dropping or returning DeviceNotRegistered-adjacent errors from Expo's push service.

Why iOS didn't break

APNs is a separate world. Apple's auth key flow (the .p8 file) didn't change, so if iOS works and Android doesn't, FCM v1 is your suspect before anything else.

Diagnosing a broken pipeline

Before you migrate, confirm the problem. The Expo push API will accept your request and return a ticket even when delivery fails downstream. You have to check the receipt, not the ticket.

Here's a minimal Node check:

const send = async () => {
  const ticketRes = await fetch('https://exp.host/--/api/v2/push/send', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      to: 'ExponentPushToken[xxxxxxxx]',
      title: 'Diag',
      body: 'Testing FCM v1',
    }),
  });
  const { data } = await ticketRes.json();
  const ticketId = data.id;

  // Wait ~15 seconds, then check the receipt
  await new Promise(r => setTimeout(r, 15000));

  const receiptRes = await fetch('https://exp.host/--/api/v2/push/getReceipts', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ ids: [ticketId] }),
  });
  console.log(await receiptRes.json());
};

If the receipt comes back with status: "error" and a details code like MismatchSenderId or a message about the legacy API, you've confirmed it. The other tell is an error mentioning that the FCM server key is no longer accepted.

The migration, step by step

There are four real steps. None of them are hard individually; the problem is that the failure mode in between steps is silent.

1. Generate a service account in Firebase

In the Firebase console, open your project settings, go to Service accounts, and generate a new private key for the Firebase Admin SDK. You'll get a JSON file. Treat it like a password — it can send pushes to every user in your project.

The service account needs the Firebase Cloud Messaging API (V1) enabled in the underlying Google Cloud project. It usually is by default, but check the API library if your first send fails with a permission error.

2. Upload it to Expo

In your Expo dashboard, go to your project's credentials, find the Android push key section, and upload the service account JSON. If you're using EAS, you can also do this with the CLI:

eas credentials
# Select Android > Production > Google Service Account Key for Push

Delete the old FCM server key entry while you're there. Leaving it sitting around invites a future engineer to wonder which one is live.

3. Confirm google-services.json is current

This catches people. The google-services.json file in your project root (or wherever your config references it) must come from the same Firebase project as the service account. If you have multiple Firebase projects — say, one for staging and one for production — and you mix them, FCM will reject the token with a sender-ID mismatch.

In app.json or app.config.ts:

{
  "expo": {
    "android": {
      "googleServicesFile": "./google-services.json",
      "package": "com.yourcompany.app"
    }
  }
}

Rebuild with EAS after any change here. OTA updates won't pick up native config — this is a native rebuild every time.

4. Re-register device tokens after the rebuild

Users who registered for push before the migration may have stale tokens. In practice, FCM tends to re-issue tokens on app upgrade, but not always. The safe move is to call getExpoPushTokenAsync on app launch and sync to your backend if the value differs from what you have stored:

import * as Notifications from 'expo-notifications';

async function refreshPushToken(userId: string, storedToken?: string) {
  const { status } = await Notifications.getPermissionsAsync();
  if (status !== 'granted') return;

  const token = (await Notifications.getExpoPushTokenAsync({
    projectId: 'your-eas-project-id',
  })).data;

  if (token !== storedToken) {
    await api.post('/devices', { userId, token });
  }
}

Pass the projectId explicitly. In EAS-built apps it's required, and a missing project ID is another silent failure mode.

Pitfalls we've actually hit

A few things that aren't in the official docs but cost us hours.

Android 13+ permission prompt

If you're targeting API 33 or higher — which you have to be for Play Store submissions in 2026 — you need to request POST_NOTIFICATIONS at runtime. expo-notifications handles this if you call requestPermissionsAsync, but if your app silently never prompted users who upgraded from Android 12, they're not receiving anything regardless of your FCM config.

Check it explicitly on launch and prompt with context, not on first cold start before the user knows what your app does. App stores are increasingly unhappy with rude permission prompts, and users dismiss them faster than you'd think.

Notification channels

Android requires channels for any notification that needs to vibrate, make sound, or show on the lock screen with any real priority. If you don't define one, the system uses a default channel that may be set to "silent" by the OS, and your push lands but nobody sees it.

await Notifications.setNotificationChannelAsync('default', {
  name: 'Default',
  importance: Notifications.AndroidImportance.HIGH,
  vibrationPattern: [0, 250, 250, 250],
  lightColor: '#FF231F7C',
});

Do this once at app startup, before any notification fires.

Data-only vs. notification messages

FCM v1 distinguishes between notification payloads (which the system displays) and data payloads (which your app handles in code). Expo's push service abstracts this, but if you send a payload with no title and no body, Android may treat it as data-only and never show a banner. Easy to do when testing silent updates and then forgetting why the next visible push doesn't appear.

Where Swift and Kotlin still win

Honestly? For 95% of apps, the Expo push pipeline is fine once it's wired correctly. Where native gets meaningfully better is in notification extensions — rich media decryption on iOS via UNNotificationServiceExtension, or custom background processing on Android that needs to run before the notification is shown.

Expo supports notification service extensions through config plugins, but the developer experience is rougher than writing the extension directly in Xcode. If your product depends on end-to-end encrypted message previews or heavy server-side personalization done client-side, budget time for a config plugin or accept a bare workflow.

For everything else — transactional pushes, marketing campaigns, background sync triggers — expo-notifications over FCM v1 is the right tool.

Where we'd start

If you suspect your Android pushes are broken right now: run the receipt-check script above against a known good device. If you see anything FCM-related in the error details, do the service account migration this week — not next sprint. The user-trust hit from a month of silent delivery failures is harder to recover from than a one-day rebuild.

If you're starting fresh, skip the legacy path entirely. Generate the service account, upload it to Expo, define your channels, and write a small end-to-end test that sends a real push to a real device on every release candidate. We've shipped enough mobile apps to know that push is the part of the stack that breaks quietly — and the only defense is a check that runs whether or not anyone remembers to look. If you'd rather hand the whole pipeline off, that's the kind of work we do at 72Technologies mobile.

#React Native#Expo#Push Notifications#Firebase#Android

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project