Push Setup — Firebase Cloud Messaging on iOS and Android
Everything between "the app has no push" and "a notification lands on a real handset": Firebase project registration, the platform config each OS needs, the APNs key iOS cannot work without, getting a token, and handling a message in all four app states.
The backend is done and live — FCM credentials verified, project
adolescent-social,GET /api/notifications/healthreportschannels.push: true. Nothing on this page needs a backend change. What the server still has isdevices.total: 0— no token has ever been registered, so no push has ever been deliverable to anyone.The API call itself is documented in the notifications guide §0; the payload you receive is §5.1 and the deep links you route on are §5.2. This page is the platform work around them.
What's new
- 2026-09-24 — New page. Push had a documented API call and no documented client setup, so nothing said how
getToken()is supposed to return anything. Covers Firebase console registration,google-services.json/GoogleService-Info.plist, the APNs auth key iOS is blocked on, Android 13'sPOST_NOTIFICATIONS, the notification channel and icon Android needs to render properly, the four app states a message can arrive in, and a verification checklist with the symptom of each failure.
0. What you need before you start
| Thing | Who has it | Needed for |
|---|---|---|
Access to the Firebase project adolescent-social | backend owner | Registering the apps, downloading config files, uploading the APNs key |
| iOS bundle id and Android package name | TBC — confirm before you register anything | They must match what you ship; a mismatch mints tokens the server will immediately disable |
| Apple Developer account with admin rights | TBC — name the owner | Minting the APNs auth key (.p8). This is the long-pole item for iOS |
| A physical iOS device | you | Push on the iOS Simulator is unreliable for FCM; treat "works on device" as the bar |
One project, one app per platform. A token minted against a different Firebase project is rejected by our sends, and the server then marks the device
disabledAt— so the symptom of "wrong project" looks like the device silently unregistering itself minutes after you registered it.
1. Install the SDK
npm i @react-native-firebase/app @react-native-firebase/messaging
cd ios && pod install
@react-native-firebase/app is required even though you only use messaging —
it is what reads the config files below. Pin both to the same version.
2. Android
2.1 Register the app and drop in the config
- Firebase console → project
adolescent-social→ Add app → Android. - Package name must be exactly the one you ship (see §0).
- Download
google-services.json→android/app/google-services.json.
2.2 Gradle
android/build.gradle:
buildscript {
dependencies {
classpath("com.google.gms:google-services:4.4.2")
}
}
android/app/build.gradle:
apply plugin: "com.google.gms.google-services"
2.3 The runtime permission (Android 13+)
On API 33+ a user who has not granted POST_NOTIFICATIONS sees no
notification, with no error anywhere — the pushes simply go nowhere. Whether
messaging().requestPermission() covers this for you depends on your
@react-native-firebase version, so request it explicitly: the call is
idempotent, and doing it yourself is correct on every version.
android/app/src/main/AndroidManifest.xml:
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
and at runtime, before registering the token:
import { PermissionsAndroid, Platform } from "react-native";
if (Platform.OS === "android" && Platform.Version >= 33) {
const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS,
);
if (granted !== PermissionsAndroid.RESULTS.GRANTED) return; // nothing to register
}
2.4 Channel and icon — do not skip these
Our server does not set a notification channel id on the message, so Android falls back to whatever you declare as the default. Without it your notifications land in an unnamed low-importance channel and never appear as heads-up banners. Same story for the icon: without a declared one, Android uses the app icon and renders it as a white square.
AndroidManifest.xml, inside <application>:
<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="clustr_default" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/ic_notification" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_color"
android:resource="@color/notification_color" />
Create the channel once at startup (importance HIGH, so it can interrupt):
// e.g. with notifee
await notifee.createChannel({
id: "clustr_default",
name: "Notifications",
importance: AndroidImportance.HIGH,
});
ic_notification must be a white-on-transparent silhouette — Android tints it.
3. iOS
3.1 Register the app and drop in the config
- Firebase console → Add app → iOS, with the bundle id you ship.
- Download
GoogleService-Info.plistand add it to the Xcode target (drag into Xcode, tick the target — copying the file into the folder is not enough).
3.2 The APNs auth key — the thing iOS is actually blocked on
Firebase cannot talk to Apple on your behalf without it. Every iOS push fails until this is uploaded, no matter how correct everything else is, and our server logs will say the send succeeded.
- Apple Developer → Certificates, Identifiers & Profiles → Keys →
+. - Enable Apple Push Notifications service (APNs), create, download the
.p8— it can be downloaded exactly once. Note the Key ID and your Team ID. - Firebase console → Project settings → Cloud Messaging → iOS app → APNs
Authentication Key → upload the
.p8with its Key ID and Team ID.
One key covers development and production, and all apps on the team. Prefer it over APNs certificates, which expire annually.
3.3 Capabilities
In Xcode, on the app target → Signing & Capabilities:
- Push Notifications
- Background Modes → tick Remote notifications
Both are required; the second is what lets a notification wake the app enough to be delivered while backgrounded.
3.4 Registration order
@react-native-firebase/messaging handles the APNs token → FCM token exchange
for you. You only need to ask for permission before requesting the token:
const status = await messaging().requestPermission();
const ok =
status === messaging.AuthorizationStatus.AUTHORIZED ||
status === messaging.AuthorizationStatus.PROVISIONAL;
If getToken() rejects or hangs on iOS, it is almost always §3.2 or §3.3, not
your code.
4. Get the token to us
Covered in full in the
notifications guide §0
— the body, the limits, and the four moments to call it (after login, after the
permission grant, on every launch, and on every onTokenRefresh, which is the
one that gets missed). In short:
const token = await messaging().getToken();
await api.post("/api/devices", {
token,
platform: Platform.OS === "ios" ? "IOS" : "ANDROID",
appVersion: DeviceInfo.getVersion(),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
});
messaging().onTokenRefresh(registerDevice); // reinstall, restore, app-data clear, FCM rotation
Idempotent on the token, so calling it unconditionally is safe. DELETE /api/devices/:id
on logout.
A user's pushes fan out to at most 20 active devices, most-recently-seen first. Dead tokens are disabled server-side on the next send, so you never have to clean up after an uninstall.
5. Receiving a message
5.1 What arrives
Our messages carry both a notification block and a data block — see
§5.1 of the notifications guide
for every key. The practical consequence of carrying both:
- Background / quit: the OS draws the notification itself. You do nothing.
- Foreground: the OS draws nothing, on either platform. If you want a banner
while the app is open, you render it (notifee or equivalent) from
onMessage.
Everything you route on is in data, and FCM delivers every data value as a
string — data.type tells you what happened, data.deepLink where to go.
5.2 The four states, all of which you must handle
| App state | Handler | Notes |
|---|---|---|
| Foreground | messaging().onMessage(cb) | Nothing is displayed for you. Render it or drop it. |
| Background, data processing | messaging().setBackgroundMessageHandler(cb) | Android only in practice — our payload does not set content-available, so iOS does not wake your JS for it. Don't put anything load-bearing here. |
| Background → tapped | messaging().onNotificationOpenedApp(cb) | Route on data.deepLink. |
| Quit → tapped (cold start) | messaging().getInitialNotification() | Call once on boot. Miss this and taps from a killed app open the home screen. |
// register at module scope, outside any component
messaging().setBackgroundMessageHandler(async () => {});
messaging().onNotificationOpenedApp((m) => route(m.data));
messaging().getInitialNotification().then((m) => m && route(m.data));
messaging().onMessage((m) => showLocalNotification(m)); // foreground only
function route(data) {
if (data?.deepLink) openDeepLink(data.deepLink); // same parser as the deep-links guide
}
data.deepLink is always present and always correct — the full table of shapes is
§5.2 of the notifications guide,
and registering the clustr:// scheme is
§3 of the deep-links guide.
5.3 Badges and collapsing, for free
- iOS badge is set by the server (Activity unread + chat unread). Do not also
increment it yourself or it double-counts. Android gets no badge — do it
from
GET /api/notifications/unread-countif you show one. - Tray entries collapse: repeat events on one thing replace each other instead of stacking, and within an hour the title aggregates ("Alice and 4 others liked your post."). Chat collapses per conversation. Nothing to implement.
6. Verify it actually works
In order. Each step has one failure symptom worth recognising.
- Token minted. Log
getToken(). Empty or throwing on iOS → §3.2 / §3.3. - Server has it.
GET /api/devices→ your row, with theidyou keep for logout. Empty → the call never fired (check it runs after login). - Server is healthy.
GET /api/notifications/health→channels.push: trueanddevices.total ≥ 1.push: falseis our problem, not yours — tell the backend; it means the FCM credential does not resolve. - Trigger one. From another account, like one of your posts (or follow you). Background the app first.
- It lands. Tray entry within a second or two. Tap it → the right screen.
| Symptom | Cause |
|---|---|
| Nothing on Android 13+, no error anywhere | POST_NOTIFICATIONS never granted (§2.3) |
| Android notification is silent / no banner, white square icon | No default channel or icon meta-data (§2.4) |
| Android fine, iOS silent | APNs auth key not uploaded (§3.2) — the single most common cause |
Your device shows disabledAt set within seconds of registering | Token minted against a different Firebase project, or the app was uninstalled |
getToken() never resolves on iOS | Simulator, or missing Push Notifications / Background Modes capability |
| Nothing while the app is open, fine when backgrounded | Working as designed — foreground display is yours to render (§5.1) |
| Notification shows, tap opens the home screen | getInitialNotification / onNotificationOpenedApp not wired, or the clustr:// scheme not registered |
| Push stops for one user after a reinstall | onTokenRefresh not registered (§4) — the classic |
Related
- Notifications guide — the Activity tab, the push payload (§5.1), deep-link shapes (§5.2), preferences
- Deep links guide — registering
clustr://and parsing it