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:
- Gets the native push token with
firebase_messaging(Android: FCM token, iOS: APNs token), - Converts this token into an Expo Push Token using Expo's token service,
- 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)
- Connect your Firebase project to your Flutter app:
dart pub global activate flutterfire_cli flutterfire configure - Create a project on expo.dev and note its project ID (UUID).
- 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
.p8key created in Apple Developer → Push Key (for the steps, see the iOS guide)
- Add your app in the NotiPilot dashboard: Expo Project ID, Expo Access Token, Android Package, iOS Bundle ID.
2. Dependencies
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
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
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_notificationspackage insideFirebaseMessaging.onMessageto create a notification on a channel with the IDdefault. Usemessage.notification?.title ?? message.data['title']for the title andmessage.notification?.body ?? message.data['message']for the body.
Checklist
-
flutterfire configurehas been run - FCM V1 key and APNs
.p8key 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 -
countryand, if possible,citysent 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. |