Developers · API v1.0.0

Flutter Integration

This guide is for Android and iOS apps built with Flutter. For the general API reference, see the Overview.

Supported versions

Component Minimum Recommended
Flutter 3.22 latest stable
Dart 3.4 latest
firebase_messaging 15.x latest
Android 6.0 (API 23) targetSdk 35+
iOS 13.0 15.0+

How it works

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

  1. Gets the native push token with firebase_messaging (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.

Your app doesn't need to be built with Expo; Expo is only used as the delivery infrastructure.

1. Prerequisites (one time)

  1. Connect your Firebase project to your Flutter app:
    Terminal
    dart pub global activate flutterfire_cli
    flutterfire configure
  2. Create a project on expo.dev and note its project ID (UUID).
  3. Upload your credentials to the Expo project (expo.dev → Project → Credentials):
    • Android: Firebase → Project Settings → Service Accounts → the JSON downloaded via Generate new private key → FCM V1 service account key
    • iOS: The APNs .p8 key created in Apple Developer → Push Key (for the steps, see the iOS guide)
  4. Add your app in the NotiPilot dashboard: Expo Project ID, Expo Access Token, Android Package, iOS Bundle ID.

2. Dependencies

Terminal
flutter pub add firebase_core firebase_messaging http flutter_secure_storage uuid package_info_plus

iOS: In Xcode, open ios/Runner.xcworkspace → Runner target → Signing & Capabilities and add Push Notifications and Background Modes → Remote notifications.

Android: minSdk 23 must be set in android/app/build.gradle. On Android 13+, the notification permission is requested by firebase_messaging.

3. lib/notipilot.dart

Dart
import 'dart:convert';
import 'dart:io' show Platform;
import 'dart:ui' show PlatformDispatcher;

import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:http/http.dart' as http;
import 'package:package_info_plus/package_info_plus.dart';
import 'package:uuid/uuid.dart';

class NotiPilot {
  NotiPilot._();
  static final NotiPilot instance = NotiPilot._();

  static const _baseUrl = 'https://app.notipilot.com/api/v1';
  static const _appId = 'YOUR-EXPO-PROJECT-ID'; // "Expo Project ID" from the dashboard
  static const _uidKey = 'notipilot_device_uid';

  final _storage = const FlutterSecureStorage();
  final _messaging = FirebaseMessaging.instance;

  Future<String> deviceUid() async {
    var uid = await _storage.read(key: _uidKey);
    if (uid == null) {
      uid = const Uuid().v4();
      await _storage.write(key: _uidKey, value: uid);
    }
    return uid;
  }

  /// Call on app launch. Requests permission, gets the token, and registers it with NotiPilot.
  Future<Map<String, dynamic>?> registerDevice({Map<String, Object?> extraAttributes = const {}}) async {
    final settings = await _messaging.requestPermission(alert: true, badge: true, sound: true);
    if (settings.authorizationStatus == AuthorizationStatus.denied) return null;

    final uid = await deviceUid();
    final expoToken = await _getExpoPushToken(uid);
    if (expoToken == null) return null;

    final info = await PackageInfo.fromPlatform();
    final locale = PlatformDispatcher.instance.locale;

    return _post('/register-device', {
      'app_id': _appId,
      'device_uid': uid,
      'token': expoToken,
      'platform': Platform.isIOS ? 'ios' : 'android',
      'provider': 'expo',
      'attributes': {
        'locale': locale.languageCode,   // "tr"
        'country': locale.countryCode,   // "TR"
        'app_version': info.version,
        ...extraAttributes,              // e.g. {'city': 'Istanbul'}
      },
    });
  }

  /// Call when the user signs in.
  Future<Map<String, dynamic>> identify(String externalId, {Map<String, Object?>? attributes}) async {
    return _post('/identify-device', {
      'app_id': _appId,
      'device_uid': await deviceUid(),
      'external_id': externalId,
      if (attributes != null) 'attributes': attributes,
    });
  }

  /// Keeps NotiPilot up to date when the token is refreshed. Call once at app startup.
  void listenForTokenRefresh() {
    _messaging.onTokenRefresh.listen((_) => registerDevice());
  }

  Future<String?> _getExpoPushToken(String uid) async {
    final isIOS = Platform.isIOS;
    String? deviceToken;

    if (isIOS) {
      // The APNs token becomes available shortly after app launch
      for (var i = 0; i < 5 && deviceToken == null; i++) {
        deviceToken = await _messaging.getAPNSToken();
        if (deviceToken == null) await Future.delayed(const Duration(seconds: 1));
      }
    } else {
      deviceToken = await _messaging.getToken();
    }
    if (deviceToken == null) return null;

    final info = await PackageInfo.fromPlatform();
    final res = await http.post(
      Uri.parse('https://exp.host/--/api/v2/push/getExpoPushToken'),
      headers: {'Content-Type': 'application/json'},
      body: jsonEncode({
        'type': isIOS ? 'apns' : 'fcm',
        'deviceId': uid.toLowerCase(),
        'development': isIOS && kDebugMode, // iOS debug builds use the APNs sandbox
        'appId': info.packageName,          // Android package / iOS bundle id
        'deviceToken': deviceToken,
        'projectId': _appId,
      }),
    );
    if (res.statusCode != 200) {
      debugPrint('[NotiPilot] Expo token exchange failed: ${res.statusCode} ${res.body}');
      return null;
    }
    return (jsonDecode(res.body)['data'] as Map<String, dynamic>)['expoPushToken'] as String;
  }

  Future<Map<String, dynamic>> _post(String path, Map<String, Object?> body, [int attempt = 0]) async {
    final res = await http.post(
      Uri.parse('$_baseUrl$path'),
      headers: {'Content-Type': 'application/json', 'Accept': 'application/json'},
      body: jsonEncode(body),
    );
    final json = res.body.isEmpty ? <String, dynamic>{} : jsonDecode(res.body) as Map<String, dynamic>;

    if ((res.statusCode == 429 || res.statusCode >= 500) && attempt < 3) {
      final wait = (json['retry_after'] as num?)?.toInt() ?? (1 << attempt);
      await Future.delayed(Duration(seconds: wait));
      return _post(path, body, attempt + 1);
    }
    if (res.statusCode >= 400) {
      debugPrint('[NotiPilot] ${res.statusCode} ${json['error']} ${json['errors'] ?? json['message']}');
    }
    return json;
  }

  /// Returns the custom `data` field sent from the dashboard.
  static Map<String, dynamic> customData(RemoteMessage message) {
    final raw = message.data['body'];
    if (raw is String) {
      try {
        return jsonDecode(raw) as Map<String, dynamic>;
      } catch (_) {}
    }
    return Map<String, dynamic>.from(message.data);
  }
}

4. main.dart

Dart
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';

import 'firebase_options.dart';
import 'notipilot.dart';

@pragma('vm:entry-point')
Future<void> _onBackgroundMessage(RemoteMessage message) async {
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
}

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
  FirebaseMessaging.onBackgroundMessage(_onBackgroundMessage);

  // iOS: show notifications even while the app is in the foreground
  await FirebaseMessaging.instance.setForegroundNotificationPresentationOptions(
    alert: true, badge: true, sound: true,
  );

  NotiPilot.instance.registerDevice(extraAttributes: {'city': 'Istanbul'});
  NotiPilot.instance.listenForTokenRefresh();

  // When a notification is tapped
  FirebaseMessaging.onMessageOpenedApp.listen((message) {
    final data = NotiPilot.customData(message);
    // e.g. data['screen'] == 'product' → navigate with Navigator
  });

  runApp(const MyApp());
}

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

Android foreground notifications: Android doesn't automatically display notifications while the app is open. If you want to show notifications in the foreground too, use the flutter_local_notifications package inside FirebaseMessaging.onMessage to create a notification on a channel with the ID default. Use message.notification?.title ?? message.data['title'] for the title and message.notification?.body ?? message.data['message'] for the body.

Checklist

  • flutterfire configure has been run
  • FCM V1 key and APNs .p8 key uploaded to the Expo project
  • Expo Project ID in the dashboard = _appId, and the package name / bundle ID in the dashboard are correct
  • Push Notifications + Remote notifications capabilities enabled on iOS
  • listenForTokenRefresh() is called at startup
  • country and, if possible, city sent as attributes

Common issues

Symptom Solution
getAPNSToken() returns null on iOS Use a physical device, and check the capabilities and notification permission.
Token conversion returns 4xx Check the projectId and appId (package name / bundle ID) values.
Registration succeeds, but no notifications arrive The Expo project is missing credentials for that platform.
404 unknown_app The app_id is not registered in the dashboard.