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.

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
messageobject. - 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.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading
Deep Linking in React Native 2026: Universal Links, App Links, and the Edge Cases That Bite
Deep linking looks trivial until a marketing campaign goes live and half your users land on the App Store instead of the screen you promised. Here's what actually works in 2026.

Expo Dev Client vs Expo Go in 2026: Stop Fighting Your Own Tooling
Expo Go is great until it isn't. Here's when to graduate to a custom dev client, what breaks during the switch, and how to keep your team productive while you migrate.

Background Tasks on React Native in 2026: What Actually Runs on iOS and Android
Background execution is the swampiest part of mobile. Here's what actually works on iOS and Android in 2026, what Expo gives you, and where you still need a native module.
