Tutorial8 min read

Capacitor push notifications on iOS without Firebase

Send push notifications to a Capacitor iOS app with APNs directly: no Firebase SDK, no GoogleService-Info.plist. Key setup, AppDelegate, tokens, the APNs details that trip people up, and a free shortcut.

Most Capacitor push tutorials start the same way: create a Firebase project, add GoogleService-Info.plist to Xcode, pull in the Firebase iOS SDK. On Android that is unavoidable, because Firebase Cloud Messaging is how Google delivers push. On iOS it is optional. Apple's own service, APNs, is what actually delivers every notification to an iPhone, and your app can talk to it without any Firebase code at all.

This guide sets up push notifications for a Capacitor app on iOS with APNs directly, keeps Firebase only where it is required (Android), and sends from one place for both platforms.

The short version. The official @capacitor/push-notifications plugin already returns the native APNs device token on iOS. Send that token to a server that holds your APNs key, and it can deliver to the phone. No Firebase SDK in the iOS app.

Why skip Firebase on iOS?

  • Less native code. No Firebase pods or Swift packages, no GoogleService-Info.plist, no method swizzling to reason about.
  • One less hop. FCM on iOS forwards to APNs anyway. Going direct means one set of error codes, from Apple, that you can act on.
  • Smaller privacy surface. One fewer third-party SDK to list in your App Store privacy details.

What you give up: FCM topic messaging on iOS and the Firebase console's composer. Both are easy to replace on the server side, as shown below.

What you need

PlatformCredentialWhere it comes from
iOSAPNs key (.p8), Key ID, Team ID, bundle IDApple Developer → Certificates, IDs & Profiles → Keys
AndroidFirebase service account JSON + google-services.jsonFirebase console → Project settings

One APNs key works for every app in your team and for both development and production builds, so you usually create it once.

1. Create the APNs key

  1. In the Apple Developer account, open Keys, add a key and enable Apple Push Notifications service (APNs).
  2. Download the .p8 file. Apple lets you download it only once, so store it safely.
  3. Note the Key ID and your Team ID. Check that your bundle ID exists under Identifiers with the Push Notifications capability, in the same team.

2. Prepare the iOS project

Install the plugin, then in Xcode add the Push Notifications capability to your app target:

npm install @capacitor/push-notifications
npx cap sync ios

Capacitor needs two small methods in AppDelegate.swift to hand the token to the plugin:

func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
    NotificationCenter.default.post(name: .capacitorDidRegisterForRemoteNotifications, object: deviceToken)
}

func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
    NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error)
}

That is all the native work. Nothing Firebase-related goes into the iOS project.

3. Get the token in your app

import { PushNotifications } from '@capacitor/push-notifications';

PushNotifications.addListener('registration', ({ value }) => {
  // On iOS this is the APNs token: 64 hex characters.
  // On Android it is an FCM registration token.
  sendTokenToYourServer(value);
});

const { receive } = await PushNotifications.requestPermissions();
if (receive === 'granted') {
  await PushNotifications.register();
}

Note the difference: the same listener gives you an APNs token on iOS and an FCM token on Android. Whatever sends your notifications has to know which is which and use the right service for each.

4. Send to APNs

Sending to APNs is an HTTP/2 request to api.push.apple.com, authenticated with a short-lived JWT signed by your .p8 key (ES256). The details that trip people up:

  • HTTP/2 only. Plain fetch in many runtimes speaks HTTP/1.1; in Node use node:http2.
  • Reuse the JWT. Apple rejects tokens refreshed too often (TooManyProviderTokenUpdates). Cache it for about 50 minutes.
  • Sandbox vs production. Builds run from Xcode get sandbox tokens, which only work on api.sandbox.push.apple.com. TestFlight and App Store builds use production.
  • Clean up. 410 Unregistered and 400 BadDeviceToken mean the token is dead. Delete it, or your device counts drift upward forever.
  • 4 KB limit. The JSON payload, including your custom data, must fit in 4,096 bytes.

None of this is hard, but together with Android (a different API, OAuth tokens from a service account, different error codes) it adds up to a small service you now own.

The shortcut: let OtaKit send

OtaKit Push is that service, free for Capacitor apps. You upload the APNs key and the Firebase service account once, register tokens with a small helper, and send to both platforms from the dashboard, the CLI or your backend. iOS goes straight to APNs; only Android uses Firebase.

npm install @capacitor/push-notifications @otakit/push
import { OtaKitPush } from '@otakit/push';

OtaKitPush.init({ appId: 'YOUR_OTAKIT_APP_ID' });

PushNotifications.addListener('registration', ({ value }) => {
  OtaKitPush.syncToken(value, { userId: currentUser?.id ?? null });
});

Then send from anywhere:

otakit push send --title "Your order shipped" --body "Track it in the app" --url /orders --platform ios

OtaKit handles the HTTP/2 connection, token caching, sandbox builds (a development token is detected on the first send and delivered through the sandbox), invalid-token cleanup and retries. The free plan covers 10,000 devices and 100,000 notifications a month. Setup details are in the push docs.

Testing on the simulator

Since Xcode 14 on Apple silicon Macs, the iOS simulator receives real remote notifications, with a sandbox token like a debug build on a device. You can also drag an .apns file onto the simulator, or run xcrun simctl push booted your.bundle.id payload.apns, to test how your app handles a notification without any server.

Android still needs Firebase

On Android there is no way around FCM for standard push: add google-services.json to android/app/ and the Capacitor template applies the Google Services plugin for you. On Android 13 and newer, notifications appear only after the user grants the notification permission. For the full Firebase route on both platforms, see our Firebase guide.

Related docs