Our old services page promised "Push Notifications with Firebase/GCM,
Pushwoosh, Amazon & Ionic Push". GCM shut down in 2019, Ionic Push is gone,
and Firebase retired the legacy FCM HTTP API in 2024. Here is what a correct
setup looks like in 2026 for an Ionic app on Capacitor 8: FCM's HTTP v1 API
on the server, APNs for iOS, and @capacitor/push-notifications in the app.
Architecture
- Device side:
@capacitor/push-notificationsregisters with APNs (iOS) or FCM (Android) and hands your code a token. - Server side: your backend stores tokens per user and sends through FCM HTTP v1. On iOS, FCM forwards to APNs — one sending API for both platforms.
- Direct APNs is only worth it if you have a reason to avoid Firebase on iOS; most teams do not.
1. Firebase project and credentials
- Create a Firebase project and add an Android app (package name) and an iOS
app (bundle ID). Download
google-services.jsonandGoogleService-Info.plist. - In the Apple Developer portal, create an APNs authentication key (
.p8). Upload it to Firebase under Project Settings → Cloud Messaging → Apple app configuration, with the Key ID and Team ID. Keys do not expire the way certificates do; use them. - Create a service account for your backend (Project Settings → Service accounts → Generate new private key). This JSON is the credential for HTTP v1; the old "server key" string no longer exists.
2. Capacitor app setup
npm install @capacitor/push-notifications
npx cap sync
iOS: in Xcode, add the Push Notifications capability and, under
Background Modes, tick Remote notifications. Put
GoogleService-Info.plist in ios/App/App/. Add the Firebase Messaging SDK
via Swift Package Manager (Capacitor 8's default) and forward the APNs token
to Firebase in AppDelegate.swift:
import UIKit
import Capacitor
import FirebaseCore
import FirebaseMessaging
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
FirebaseApp.configure()
return true
}
func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
Messaging.messaging().apnsToken = deviceToken
Messaging.messaging().token { token, error in
if let error = error {
NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error)
} else if let token = token {
NotificationCenter.default.post(name: .capacitorDidRegisterForRemoteNotifications, object: token)
}
}
}
func application(_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error) {
NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error)
}
}
This is the pattern from the
Capacitor push docs:
the plugin's registration event then yields the FCM token on iOS, not
the raw APNs token, so the server can treat both platforms the same.
Android: put google-services.json in android/app/ and make sure the
Google Services Gradle plugin is applied (the Capacitor template includes it
when the file is present). Since Android 13 you must request the
POST_NOTIFICATIONS runtime permission — the plugin's requestPermissions()
does that.
3. Registering and handling in TypeScript
import { Capacitor } from '@capacitor/core';
import { PushNotifications, type Token, type PushNotificationSchema,
type ActionPerformed } from '@capacitor/push-notifications';
export async function initPush(saveToken: (token: string) => Promise<void>) {
if (!Capacitor.isNativePlatform()) return; // web: use the Web Push API instead
await PushNotifications.addListener('registration', async (token: Token) => {
await saveToken(token.value); // POST to your API with the user's auth
});
await PushNotifications.addListener('registrationError', (err) => {
console.error('Push registration failed', err.error);
});
// Foreground delivery: the OS does not show a banner; you decide.
await PushNotifications.addListener('pushNotificationReceived',
(n: PushNotificationSchema) => {
// e.g. show an ion-toast, refresh a badge count, or ignore
console.log('Received in foreground', n.title, n.data);
});
// User tapped the notification (app was backgrounded or closed).
await PushNotifications.addListener('pushNotificationActionPerformed',
(action: ActionPerformed) => {
const route = action.notification.data?.route;
if (route) window.location.assign(route); // or your router's navigate()
});
let perm = await PushNotifications.checkPermissions();
if (perm.receive === 'prompt') perm = await PushNotifications.requestPermissions();
if (perm.receive !== 'granted') return;
await PushNotifications.register();
}
Two rules we enforce in client work: call register() only after the user
has signed in and you have something to associate the token with, and send
the token to your server every launch — tokens rotate, and a stale table
is the usual cause of "notifications stopped for some users".
4. Sending with FCM HTTP v1
The v1 API is authenticated with an OAuth 2.0 access token minted from the service-account JSON. The Firebase Admin SDK does this for you (Node shown; Admin SDKs exist for Java, Python, Go, and .NET):
import { initializeApp, cert } from 'firebase-admin/app';
import { getMessaging } from 'firebase-admin/messaging';
initializeApp({ cert: cert(JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT!)) });
export async function sendOrderShipped(deviceToken: string, orderId: string) {
await getMessaging().send({
token: deviceToken,
notification: { title: 'Order shipped', body: `Order ${orderId} is on its way.` },
data: { route: `/orders/${orderId}` },
apns: {
payload: { aps: { sound: 'default', badge: 1 } },
},
android: {
priority: 'high',
notification: { channelId: 'orders' },
},
});
}
If you call the REST endpoint directly instead, it is
POST https://fcm.googleapis.com/v1/projects/{project-id}/messages:send
with a Bearer access token scoped to
https://www.googleapis.com/auth/firebase.messaging; the
FCM v1 reference
has the authorisation details. Note the shape: platform-specific options live
under apns and android, not at the top level as they did in the legacy
API.
5. Tokens vs topics
- Token messaging — one device, one message. Use for anything user-specific (order status, messages). Store tokens per device, not per user; users have several devices.
- Topic messaging — subscribe devices to a topic (
news,region-tx) and send once. Subscription happens server-side withgetMessaging().subscribeToTopic(tokens, 'news'); no client SDK call is needed with Capacitor. - Multicast —
sendEachForMulticast()takes up to 500 tokens per call and returns per-token results. Delete tokens that come back withmessaging/registration-token-not-registered; this is how you keep the table clean.
6. Background and data-only messages
A message with a notification block is displayed by the OS when the app is
in the background; a data-only message is delivered to your handler
silently. On iOS a data-only message needs
apns.payload.aps['content-available'] = 1 and
apns.headers['apns-push-type'] = 'background', is throttled by the system,
and is never guaranteed. Design for "the next time the app opens, it
refreshes" rather than "the background push did the work".
Android users on 13+ can revoke the notification permission at any time;
check checkPermissions() on launch and show an in-app explanation before
asking again.
7. Testing matrix
| Case | iOS | Android |
|---|---|---|
| Fresh install, permission prompt | Simulator cannot receive APNs — use a device | Emulator with Google Play services works |
Foreground delivery → pushNotificationReceived | Device | Device / emulator |
Background tap → pushNotificationActionPerformed | Device | Device / emulator |
| App killed, then tap | Device | Device (check battery optimisation settings) |
| Token rotation after reinstall | Device | Device |
| Data-only message | Device, app backgrounded | Emulator |
| Release build (not debug) | TestFlight build | Internal testing track |
Use the Firebase console's Send test message with a pasted token for the first pass, then your real backend. Keep a tiny CLI script in the repo that sends to one token; it saves an hour every time someone says "push is broken".
Alternatives
OneSignal and Amazon Pinpoint/SNS are both fine if you want a hosted campaign UI; they sit on the same FCM/APNs rails underneath and the Capacitor side is nearly identical. What we no longer recommend is anything that describes itself in terms of GCM, server keys, or Ionic Push — those integrations do not work any more.
Need this wired into an existing app, or a stuck notification pipeline diagnosed? Contact our team.