Developers · API v1.0.0

Ionic (Capacitor) Integration

This guide is for Android and iOS apps built with Ionic + Capacitor (Angular, React, or Vue — it doesn't matter). For the general API reference, see the Overview.

Supported versions

Component Minimum Recommended
Capacitor 6 latest
@capacitor/push-notifications 6.x latest
Ionic Framework 7 latest
Android 6.0 (API 23) targetSdk 35+
iOS 13.0 15.0+

Legacy Cordova-based Ionic projects are not officially supported; we recommend migrating to Capacitor. PWA/web push is not supported in 1.0.0.

How it works

NotiPilot 1.0.0 delivers notifications through the Expo Push Service. Your app:

  1. Gets the native token with @capacitor/push-notifications (Android: FCM token, iOS: APNs token),
  2. Converts this token into an Expo Push Token using Expo's token service,
  3. Registers the Expo Push Token with NotiPilot via register-device.

1. Prerequisites (one time)

  1. Android: Add your Android app in the Firebase console and place the google-services.json file in android/app/.
  2. iOS: Enable the Push Notifications capability in Apple Developer and create an APNs .p8 key (steps: iOS guide).
  3. Create a project on expo.dev, note its project ID, and upload the FCM V1 service account JSON (Android) and the APNs .p8 key (iOS) under Credentials.
  4. Add your app in the NotiPilot dashboard: Expo Project ID, Expo Access Token, Android Package, iOS Bundle ID (= appId in capacitor.config.ts).

2. Installation

Terminal
npm install @capacitor/push-notifications @capacitor/preferences @capacitor/app @capacitor/device
npx cap sync

iOS: In Xcode (npx cap open ios), go to App target → Signing & Capabilities and add Push Notifications and Background Modes → Remote notifications. Then add the following to ios/App/App/AppDelegate.swift:

Swift
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)
}

Android: minSdkVersion = 23 must be set in android/variables.gradle.

capacitor.config.ts:

TypeScript
const config: CapacitorConfig = {
  appId: 'com.example.app',
  appName: 'Example',
  webDir: 'dist',
  plugins: {
    PushNotifications: { presentationOptions: ['badge', 'sound', 'alert'] },
  },
};

3. src/notipilot.ts

TypeScript
import { Capacitor } from '@capacitor/core';
import { PushNotifications, type Token, type ActionPerformed } from '@capacitor/push-notifications';
import { Preferences } from '@capacitor/preferences';
import { App } from '@capacitor/app';
import { Device } from '@capacitor/device';

const NOTIPILOT_BASE_URL = 'https://app.notipilot.com/api/v1';
const APP_ID = 'YOUR-EXPO-PROJECT-ID'; // "Expo Project ID" from the dashboard
const UID_KEY = 'notipilot_device_uid';

type Attributes = Record<string, string | number | boolean | null>;

async function deviceUid(): Promise<string> {
  const { value } = await Preferences.get({ key: UID_KEY });
  if (value) return value;
  const uid = crypto.randomUUID();
  await Preferences.set({ key: UID_KEY, value: uid });
  return uid;
}

async function post(path: string, body: object, attempt = 0): Promise<any> {
  const res = await fetch(`${NOTIPILOT_BASE_URL}${path}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
    body: JSON.stringify(body),
  });
  const json = await res.json().catch(() => ({}));
  if ((res.status === 429 || res.status >= 500) && attempt < 3) {
    await new Promise((r) => setTimeout(r, (Number(json.retry_after) || 2 ** attempt) * 1000));
    return post(path, body, attempt + 1);
  }
  if (!res.ok) console.warn('[NotiPilot]', res.status, json.error, json.errors ?? json.message);
  return json;
}

async function toExpoPushToken(nativeToken: string, uid: string): Promise<string> {
  const isIOS = Capacitor.getPlatform() === 'ios';
  const { id: bundleId } = await App.getInfo();

  const res = await fetch('https://exp.host/--/api/v2/push/getExpoPushToken', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      type: isIOS ? 'apns' : 'fcm',
      deviceId: uid.toLowerCase(),
      development: isIOS && import.meta.env.DEV, // iOS debug builds use the APNs sandbox
      appId: bundleId,
      deviceToken: nativeToken,
      projectId: APP_ID,
    }),
  });
  if (!res.ok) throw new Error(`Expo token exchange failed: ${res.status}`);
  return (await res.json()).data.expoPushToken;
}

/** Call on app launch. */
export async function initNotiPilot(extraAttributes: Attributes = {}) {
  if (!Capacitor.isNativePlatform()) return;

  await PushNotifications.addListener('registration', async (token: Token) => {
    try {
      const uid = await deviceUid();
      const expoToken = await toExpoPushToken(token.value, uid);
      const { languageCode } = await Device.getLanguageCode();
      const { version } = await App.getInfo();
      const region = new Intl.Locale(navigator.language).region ?? null;

      await post('/register-device', {
        app_id: APP_ID,
        device_uid: uid,
        token: expoToken,
        platform: Capacitor.getPlatform(), // 'ios' | 'android'
        provider: 'expo',
        attributes: {
          locale: languageCode.slice(0, 2),  // "tr"
          country: region,                   // "TR"
          app_version: version,
          ...extraAttributes,                // e.g. { city: 'Istanbul' }
        },
      });
    } catch (e) {
      console.warn('[NotiPilot] register failed', e);
    }
  });

  await PushNotifications.addListener('registrationError', (err) =>
    console.warn('[NotiPilot] native registration error', err),
  );

  // Notification tapped — custom data sent from the dashboard
  await PushNotifications.addListener('pushNotificationActionPerformed', (action: ActionPerformed) => {
    const raw = action.notification.data?.body;
    const data = typeof raw === 'string' ? safeJson(raw) : raw ?? action.notification.data;
    // e.g. data.screen === 'product' → router.navigate(...)
  });

  if (Capacitor.getPlatform() === 'android') {
    await PushNotifications.createChannel({ id: 'default', name: 'General', importance: 5 });
  }

  let perm = await PushNotifications.checkPermissions();
  if (perm.receive === 'prompt') perm = await PushNotifications.requestPermissions();
  if (perm.receive === 'granted') await PushNotifications.register();
}

/** Call when the user signs in. */
export async function identify(externalId: string, attributes?: Attributes) {
  return post('/identify-device', {
    app_id: APP_ID,
    device_uid: await deviceUid(),
    external_id: externalId,
    ...(attributes ? { attributes } : {}),
  });
}

function safeJson(s: string) {
  try { return JSON.parse(s); } catch { return {}; }
}

import.meta.env.DEV is for Vite. If you use Angular, use !environment.production; in Webpack-based projects, use process.env.NODE_ENV !== 'production'.

Calling PushNotifications.register() on every app launch fires the registration event again, so token changes are automatically forwarded to NotiPilot.

4. Usage

Angular (app.component.ts):

TypeScript
constructor() { initNotiPilot({ city: 'Istanbul' }); }

React (App.tsx):

TSX
useEffect(() => { initNotiPilot({ city: 'Istanbul' }); }, []);

Vue (App.vue):

TypeScript
onMounted(() => initNotiPilot({ city: 'Istanbul' }));

When the user signs in: await identify(user.id);

Checklist

  • google-services.json added (Android), Push capabilities enabled (iOS)
  • Capacitor push methods added to iOS AppDelegate.swift
  • FCM V1 and APNs credentials uploaded to the Expo project
  • Expo Project ID in the dashboard = APP_ID, Bundle ID / Package = capacitor.config.ts → appId
  • country and, if possible, city sent as attributes

Common issues

Symptom Solution
The registration event never fires (iOS) The AppDelegate.swift methods are missing or the capability is disabled.
The app crashes on Android google-services.json is missing.
Token conversion returns 4xx Check the projectId and appId (bundle ID) values.
404 unknown_app The app_id is not registered in the dashboard.