Register a Capacitor app's APNs and FCM tokens with Native Notify, show pushes while the app is open, and show images.
1. Set up your Capacitor project
In your terminal, from the project folder:
npm install @capacitor/push-notifications @capacitor/device
npx cap sync
On iOS, run npx cap open ios. In Xcode, select the App target and add the Push Notifications capability under Signing & Capabilities. Native Notify sends straight to APNs with your .p8 key, so Firebase needs no APNs key.
On Android, register the app in the Firebase console under its exact applicationId and save google-services.json in android/app/. Use the same Firebase project whose service-account JSON you save in step 5, or sends fail with SENDER_ID_MISMATCH. Capacitor's template applies the Google services plugin once that file exists.
2. Register the device
import { Capacitor } from "@capacitor/core";
import { Device } from "@capacitor/device";
import { PushNotifications } from "@capacitor/push-notifications";
const REGISTER_URL = "https://app.nativenotify.com/api/universal/device/register";
type Args = { appId: number; appToken: string; subscriberId?: string };
async function postRegistration({ appId, appToken, subscriberId }: Args, token: string) {
const platform = Capacitor.getPlatform(); // "ios" or "android"
const res = await fetch(REGISTER_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
appId,
appToken,
deviceId: (await Device.getId()).identifier, // one stable id per install
platform,
subscriberId, // your logged-in user's id; omit for an anonymous device
// The plugin hands over the APNs token in uppercase hex on iOS, the FCM token on Android.
tokens: platform === "ios" ? { apnsToken: token.toLowerCase() } : { fcmToken: token },
}),
});
if (res.status !== 201) {
// 400/403 answer { error: { code, message } }; a wrong appId/appToken answers 401 as plain text.
throw new Error(`Native Notify register failed (${res.status}): ${await res.text()}`);
}
}
// Call once on every launch. The plugin hands you the token, and hands you the next one whenever it changes.
export async function registerUniversalDevice(args: Args) {
if (!Capacitor.isNativePlatform()) return; // the browser has no push plugin
const permission = await PushNotifications.requestPermissions();
if (permission.receive !== "granted") return;
await PushNotifications.addListener("registration", (token) => {
postRegistration(args, token.value).catch((err) => console.warn("Native Notify:", err));
});
await PushNotifications.addListener("registrationError", (error) => console.warn("Native Notify: no push token —", error.error));
await PushNotifications.register();
}
Call registerUniversalDevice({ appId, appToken, subscriberId }) on every launch. The plugin calls the registration listener again whenever the token changes, so a rotated token registers again.
3. Show pushes on iOS while the app is open
The plugin shows nothing on iOS while the app is open until you set presentationOptions. Add it to your Capacitor config, then run npx cap sync in your terminal:
import type { CapacitorConfig } from "@capacitor/cli";
const config: CapacitorConfig = {
// keep your appId, appName and webDir
plugins: {
PushNotifications: {
presentationOptions: ["badge", "sound", "banner", "list"],
},
},
};
export default config;
{
"plugins": {
"PushNotifications": {
"presentationOptions": ["badge", "sound", "banner", "list"]
}
}
}
4. Show pushes on Android
Android pushes are FCM data messages: Firebase never draws them, so your app does. This service draws every push in every app state, and still hands each message and token to the Capacitor plugin, so its registration, pushNotificationReceived and pushNotificationActionPerformed listeners keep working.
In Android Studio, add three files in android/app/src/main/java/com/example/app/, the folder of your app's package (the namespace in android/app/build.gradle). Change the package line in each if yours differs.
// Native Notify — shows pushes on Android.
package com.example.app;
import com.google.firebase.messaging.FirebaseMessagingService;
import com.google.firebase.messaging.RemoteMessage;
import java.lang.reflect.Method;
/**
* Receives every FCM message and token. It draws the notification (NativeNotifyDisplay) and hands the message and the token
* to the Capacitor push plugin, which the plugin's own service would have done — through reflection, so this file compiles
* whether or not @capacitor/push-notifications is installed yet.
*/
public class NativeNotifyMessagingService extends FirebaseMessagingService {
private static final String PLUGIN = "com.capacitorjs.plugins.pushnotifications.PushNotificationsPlugin";
@Override
public void onNewToken(String token) {
super.onNewToken(token);
callPlugin("onNewToken", String.class, token);
}
@Override
public void onMessageReceived(RemoteMessage message) {
super.onMessageReceived(message);
callPlugin("sendRemoteMessage", RemoteMessage.class, message);
if (NativeNotifyDisplay.shouldDraw(this, message)) NativeNotifyDisplay.show(this, message);
}
private static void callPlugin(String method, Class<?> type, Object argument) {
try {
Method target = Class.forName(PLUGIN).getMethod(method, type);
target.invoke(null, argument);
} catch (Exception ignored) {
// the plugin is not installed (yet), or its app is not running: nothing to hand over
}
}
}
// Native Notify — shows pushes on Android.
//
// An Android push is an FCM *data* message: Firebase never draws it, the app must. NativeNotifyMessagingService
// hands every message it receives to show() — in every app state — and to the Capacitor push plugin,
// so that plugin's listeners (registration, pushNotificationReceived, pushNotificationActionPerformed)
// keep working exactly as before. A push with a picture is drawn with the picture.
package com.example.app;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.os.Build;
import android.os.Looper;
import android.util.Log;
import com.google.firebase.messaging.RemoteMessage;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.Locale;
import java.util.Map;
final class NativeNotifyDisplay {
private static final String TAG = "NativeNotify";
static final String DEFAULT_CHANNEL = "default";
private static final int MAX_IMAGE_BYTES = 5 * 1024 * 1024;
private static final int MAX_IMAGE_SIDE = 1024;
private NativeNotifyDisplay() {}
/**
* Whether this service should draw the message. A push without a picture is a data message nobody else draws. A push with a
* picture also has a notification block: Firebase draws it while the app is closed or in the background (this service is not
* called then), and while the app is open the Capacitor push plugin draws it only when capacitor.config sets
* plugins.PushNotifications.presentationOptions — otherwise nobody does, so this service does.
*/
static boolean shouldDraw(Context context, RemoteMessage message) {
return message.getNotification() == null || !pluginPresents(context);
}
private static boolean pluginPresents(Context context) {
try (InputStream in = context.getAssets().open("capacitor.config.json")) {
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buffer = new byte[4096];
int read;
while ((read = in.read(buffer)) >= 0) out.write(buffer, 0, read);
JSONObject plugins = new JSONObject(out.toString("UTF-8")).optJSONObject("plugins");
JSONObject push = plugins == null ? null : plugins.optJSONObject("PushNotifications");
JSONArray options = push == null ? null : push.optJSONArray("presentationOptions");
if (options == null) return false;
for (int i = 0; i < options.length(); i++) {
String option = options.optString(i);
if ("alert".equals(option) || "banner".equals(option) || "list".equals(option)) return true;
}
} catch (Exception ignored) {
// no readable capacitor.config.json: the plugin shows nothing, so this service does
}
return false;
}
/** The notification channel Firebase and this service use (created at app start by NativeNotifyInitProvider). */
static void createDefaultChannel(Context context) {
createChannel(context, DEFAULT_CHANNEL);
}
/** Draw a Native Notify message: its title and text and, when the push has one, its image. */
static void show(Context context, RemoteMessage message) {
final Map<String, String> data = message.getData();
final String title = data.get("title");
if (title == null) return;
final Context app = context.getApplicationContext();
final int id = (int) System.currentTimeMillis();
RemoteMessage.Notification notification = message.getNotification();
final String imageUrl = notification != null && notification.getImageUrl() != null ? notification.getImageUrl().toString() : null;
final String messageId = message.getMessageId();
if (imageUrl == null || imageUrl.isEmpty()) {
post(app, id, messageId, data, title, null);
} else if (Looper.myLooper() == Looper.getMainLooper()) {
// Never download on the main thread: show the text now and swap in the picture when it arrives.
post(app, id, messageId, data, title, null);
new Thread(() -> {
Bitmap picture = loadImage(imageUrl);
if (picture != null) post(app, id, messageId, data, title, picture);
}).start();
} else {
post(app, id, messageId, data, title, loadImage(imageUrl));
}
}
private static void post(Context app, int id, String messageId, Map<String, String> data, String title, Bitmap picture) {
String text = data.get("message") != null ? data.get("message") : "";
String channelId = data.get("channelId") != null ? data.get("channelId") : DEFAULT_CHANNEL;
NotificationManager manager = (NotificationManager) app.getSystemService(Context.NOTIFICATION_SERVICE);
createChannel(app, channelId);
// A tap opens the app. The push's data travels as plain extras, with the message id, exactly like a notification Firebase
// drew itself — that is what makes the Capacitor push plugin report it as pushNotificationActionPerformed.
Intent launch = app.getPackageManager().getLaunchIntentForPackage(app.getPackageName());
PendingIntent tap = null;
if (launch != null) {
launch.putExtra("google.message_id", messageId != null ? messageId : "native-notify-" + id);
for (Map.Entry<String, String> entry : data.entrySet()) launch.putExtra(entry.getKey(), entry.getValue());
int flags = PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0);
tap = PendingIntent.getActivity(app, id, launch, flags);
}
Notification.Builder builder = Build.VERSION.SDK_INT >= 26 ? new Notification.Builder(app, channelId) : new Notification.Builder(app);
builder.setSmallIcon(app.getApplicationInfo().icon).setContentTitle(title).setContentText(text).setAutoCancel(true);
if (tap != null) builder.setContentIntent(tap);
if (picture != null) {
builder.setLargeIcon(picture);
builder.setStyle(new Notification.BigPictureStyle().bigPicture(picture).bigLargeIcon((Bitmap) null));
}
manager.notify(id, builder.build());
}
/** Download the push's image (https only, at most 5 MB, 8 seconds) and shrink it to a size a notification can show. null = no picture. */
private static Bitmap loadImage(String url) {
if (!url.toLowerCase(Locale.ROOT).startsWith("https://")) return null;
HttpURLConnection connection = null;
try {
connection = (HttpURLConnection) new URL(url).openConnection();
connection.setConnectTimeout(8000);
connection.setReadTimeout(8000);
int code = connection.getResponseCode();
if (code < 200 || code > 299) return null;
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buffer = new byte[8192];
try (InputStream in = connection.getInputStream()) {
int read;
while ((read = in.read(buffer)) >= 0) {
if (out.size() + read > MAX_IMAGE_BYTES) return null;
out.write(buffer, 0, read);
}
}
return decode(out.toByteArray());
} catch (Exception e) {
Log.w(TAG, "the notification image could not be loaded — " + e.getMessage());
return null;
} finally {
if (connection != null) connection.disconnect();
}
}
private static Bitmap decode(byte[] bytes) {
BitmapFactory.Options bounds = new BitmapFactory.Options();
bounds.inJustDecodeBounds = true;
BitmapFactory.decodeByteArray(bytes, 0, bytes.length, bounds);
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null;
int sample = 1;
while (Math.max(bounds.outWidth, bounds.outHeight) / sample > MAX_IMAGE_SIDE) sample *= 2;
BitmapFactory.Options options = new BitmapFactory.Options();
options.inSampleSize = sample;
return BitmapFactory.decodeByteArray(bytes, 0, bytes.length, options);
}
private static void createChannel(Context context, String id) {
if (Build.VERSION.SDK_INT < 26) return;
NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
if (manager.getNotificationChannel(id) == null) {
manager.createNotificationChannel(new NotificationChannel(id, "Notifications", NotificationManager.IMPORTANCE_HIGH));
}
}
}
// Native Notify — shows pushes on Android.
package com.example.app;
import android.content.ContentProvider;
import android.content.ContentValues;
import android.database.Cursor;
import android.net.Uri;
/** Starts with the app (the trick Firebase itself uses): creates the notification channel Firebase draws a closed-app push on. */
public class NativeNotifyInitProvider extends ContentProvider {
@Override
public boolean onCreate() {
if (getContext() != null) NativeNotifyDisplay.createDefaultChannel(getContext());
return true;
}
@Override public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { return null; }
@Override public String getType(Uri uri) { return null; }
@Override public Uri insert(Uri uri, ContentValues values) { return null; }
@Override public int delete(Uri uri, String selection, String[] selectionArgs) { return 0; }
@Override public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { return 0; }
}
Register the service, the provider and Firebase's default channel and icon inside <application>. The service's priority makes it win over the plugin's own:
<service
android:name=".NativeNotifyMessagingService"
android:exported="false">
<intent-filter android:priority="100">
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<provider
android:name=".NativeNotifyInitProvider"
android:authorities="${applicationId}.native-notify-init"
android:exported="false" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="default" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@mipmap/ic_launcher" />
Add Firebase Messaging to the app module, next to the Capacitor dependency:
dependencies {
implementation project(':capacitor-android')
implementation "com.google.firebase:firebase-messaging:${rootProject.ext.has('firebaseMessagingVersion') ? rootProject.ext.firebaseMessagingVersion : '25.0.1'}"
}
If you turn on code shrinking (minifyEnabled true), keep the two plugin methods the service calls:
-keepclassmembers class com.capacitorjs.plugins.pushnotifications.PushNotificationsPlugin {
public static void sendRemoteMessage(com.google.firebase.messaging.RemoteMessage);
public static void onNewToken(java.lang.String);
}
Run npx cap sync android in your terminal, then run the app again from Android Studio: native code needs a new build.
5. Save credentials in Native Notify
In Native Notify, open your project's Settings → Push credentials and save the same Firebase project's service-account JSON and your Apple .p8 key. See Push credentials.
6. Send a push with the API
Check one device first with a test send. For other audiences, see Send notifications. This call reaches every registered device:
curl -X POST https://app.nativenotify.com/api/universal/notifications/send \
-H "Content-Type: application/json" \
-d '{"appId":123,"appToken":"yourAppToken","title":"Hello Capacitor","message":"Sent through APNs/FCM.","audience":{"type":"all"}}'
Show images
On Android a picture shows in every app state: Firebase draws it while the app is closed or in the background, on the default channel the manifest names, and the code in step 4 covers an open app.
On iPhone, add a notification service extension to the App target: run npx cap open ios in your terminal, then follow Show images in the Swift recipe. The iPhone Simulator never runs it, so test on an iPhone. Send a push with a bigPictureURL: an https JPEG or PNG under 1 MB.
Next steps
- Device registration — fields, deregister and errors.
- Groups — let users subscribe from the app with
POST /api/universal/groups/subscribe, then send to the group.