+1 (415) 843-4662

Push notifications in 2026: FCM HTTP v1 + APNs with Capacitor

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-notifications registers 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

  1. Create a Firebase project and add an Android app (package name) and an iOS app (bundle ID). Download google-services.json and GoogleService-Info.plist.
  2. 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.
  3. 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 with getMessaging().subscribeToTopic(tokens, 'news'); no client SDK call is needed with Capacitor.
  • MulticastsendEachForMulticast() takes up to 500 tokens per call and returns per-token results. Delete tokens that come back with messaging/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

CaseiOSAndroid
Fresh install, permission promptSimulator cannot receive APNs — use a deviceEmulator with Google Play services works
Foreground delivery → pushNotificationReceivedDeviceDevice / emulator
Background tap → pushNotificationActionPerformedDeviceDevice / emulator
App killed, then tapDeviceDevice (check battery optimisation settings)
Token rotation after reinstallDeviceDevice
Data-only messageDevice, app backgroundedEmulator
Release build (not debug)TestFlight buildInternal 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.