Reteno Flutter iOS SDK Setup
The plugin and Reteno 2.7.3 podspecs declare iOS 12.0 as the technical minimum, with iOS 14.0 or later recommended and Xcode 15.0+ and Swift 5.7+ required.
This guide uses unpinned FlutterFire, which raises those minimums. As of August 2026, FlutterFire resolves Firebase Apple SDK 12.x, which requires iOS 15.0, Dart 3.6.0, Flutter 3.27.0, Xcode 26.2 or later, and CocoaPods 1.12.0 or later. See the FlutterFire version table and the Firebase Apple release notes. Use the highest minimum your resolved dependencies require, and give Runner and every notification extension the same target.
1. Install the plugin and configure Firebase
flutter pub add reteno_plugin:1.11.0
flutter pub add firebase_coreCommit pubspec.lock so the resolved FlutterFire versions stay reproducible.
The plugin and the extensions below use CocoaPods. On Flutter 3.44 or later, where Swift Package Manager is on by default, keep the project on one dependency manager:
flutter:
config:
enable-swift-package-manager: falseAdd an iOS app with the exact Runner bundle identifier to Firebase, run flutterfire configure, and keep the generated configuration in the project. See Firebase setup for Flutter.
The plugin links FirebaseMessaging, but token ownership depends on the mode: automatic reports APNs only, manual observes the FCM token without replacing the application's MessagingDelegate, and external leaves Firebase entirely to the application. Firebase must be initialized before Reteno in manual and external; an APNs-only application can skip the Dart Firebase initialization.
2. Add a Notification Service Extension
NotificationServiceExtension enables rich notifications and delivered-status reporting. Taps and actions are handled separately by the notification delegate coordinator; keep both configured.
- In Xcode, select File ā New ā Target, then Notification Service Extension.
- Name the target
NotificationServiceExtensionand finish creating it. Select Cancel in the scheme-activation prompt so Xcode keeps runningRunner.
- Set the extension deployment target to the same value as
Runnerā iOS 15.0 for the current FlutterFire flow. The screenshot below shows the bundled example's iOS 14.0 target.
- Replace
NotificationService.swiftwith:
import UserNotifications
import Reteno
class NotificationService: RetenoNotificationServiceExtension {}The unresolved Reteno import is expected until the extension pod is installed in the next step. See Apple's Notification Service Extension documentation.
3. Add Reteno to the extension target
The Flutter plugin already pins Reteno 2.7.3 for Runner, so do not repeat it there. Keep the Flutter-generated Podfile structure, set the platform, and add the same version to each notification extension:
platform :ios, '15.0'
target 'Runner' do
use_frameworks!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
end
target 'NotificationServiceExtension' do
use_frameworks!
pod 'Reteno', '2.7.3'
endAdd the same pod to NotificationContentExtension if the application has one. If post_install hard-codes IPHONEOS_DEPLOYMENT_TARGET, update it to the effective application target.
Then install the pods and open the generated workspace:
flutter pub get
cd ios
pod install
open Runner.xcworkspaceKeep Podfile.lock under version control so Runner and every extension resolve the same native SDK.
When upgrading, an older Podfile.lock can pin an incompatible Firebase line and make pod install report a conflict. Update the FlutterFire constraints first, then run a targeted pod update Firebase/CoreOnly Firebase/Messaging FirebaseCore FirebaseMessaging from ios and commit the resulting lockfile.
4. Configure App Groups
Add the same App Group capability to both Runner and NotificationServiceExtension:
group.<main-app-bundle-id>.reteno-local-storageUse the main application bundle identifier in both targets; do not include the extension suffix.
- Open Signing & Capabilities for
Runnerand select + Capability.
- Select App Groups and add the group shown above.
- Open Signing & Capabilities for
NotificationServiceExtensionand enable the exact same group.
Both provisioning profiles must contain the App Group entitlement. If you correct the group after the SDK has already run, delete and reinstall the app before retesting shared storage.
For more information, see Configuring App Groups.
5. Enable push and connect the provider
Add the Push Notifications capability to the Runner target. Do not add it to NotificationServiceExtension.
Then connect the mobile app in Reteno ā Settings ā Mob Push:
- For direct APNs, upload the
.p8key or.p12certificate to Reteno with a Topic that matches theRunnerbundle identifier. - For FCM, add the APNs authentication key to the Firebase iOS app, upload the Firebase service-account JSON to Reteno, and enable Background Modes ā Background fetch and Remote notifications for
Runner.
Leave FirebaseAppDelegateProxyEnabled enabled; the FCM flow described here relies on Firebase method swizzling.
6. Initialize Firebase, then Reteno
Initialize both SDKs once during application startup:
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/widgets.dart';
import 'package:reteno_plugin/reteno.dart';
import 'firebase_options.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// Broadcast streams do not replay. Install application-lifetime listeners
// before Reteno initialization can flush a queued cold-start event.
Reteno.onUserNotificationAction.listen((action) {
// Route the notification action.
});
Reteno.onRetenoNotificationClicked.listen((payload) {
// Route the notification click.
});
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
await Reteno().initialize(
accessKey: '<your_access_key>',
options: RetenoInitOptions(
lifecycleTrackingOptions: LifecycleTrackingOptions.all(),
deviceTokenHandlingMode: RetenoDeviceTokenHandlingMode.automatic,
),
);
runApp(const MyApp());
}Register every Reteno push and action stream synchronously, before awaiting initialize(). Events queued during cold launch are flushed while initialization completes, and these broadcast streams do not replay them. getInitialNotification() recovers a launch payload for a plain tap, but not the action stream's actionId, custom data, and link.
Choose the final token mode before this call: the example uses automatic, manual gives the plugin FCM ownership, and external uses the alternative in section 7. From version 1.11.0, a second initialize() with a different mode fails, because native token ownership is fixed by the first call.
From version 1.11.0, customDeviceId is also supported on iOS. The provider is awaited once before native initialization, and null selects Reteno's default provider without erasing an ID stored by an earlier custom-ID integration:
await Reteno().initialize(
accessKey: '<your_access_key>',
customDeviceId: () async => await loadStableDeviceId(),
options: const RetenoInitOptions(
deviceTokenHandlingMode: RetenoDeviceTokenHandlingMode.automatic,
),
);Return one stable, non-empty value across launches. A blank value throws ArgumentError, a non-blank value is forwarded verbatim, and there is no timeout, so a provider that never completes leaves initialize() pending. Enabling this option on an existing installation replaces the stored default ID and changes that device's identity in Reteno; test the migration against a non-production mobile app first.
Do not call Reteno.start(...), register a native Reteno MessagingDelegate, or pass a token to the native Reteno SDK from AppDelegate. The Flutter plugin owns initialization and token forwarding; in external mode, synchronize the token only through the Dart setPushToken() API.
7. Select the iOS push token mode
Choose the mode that matches the notification service configured for the mobile app in Reteno:
| Reteno mobile app configuration | Token mode | Token owner |
|---|---|---|
| APNs for iOS, including FCM for Android + APNs for iOS | RetenoDeviceTokenHandlingMode.automatic | The bridge reports the APNs token |
| FCM-only; the plugin manages the token | RetenoDeviceTokenHandlingMode.manual | The plugin reports the current and refreshed FCM token |
| FCM-only; the application owns Firebase token handling | RetenoDeviceTokenHandlingMode.external | The application reports the token with setPushToken() |
Never send both an APNs token and an FCM token for the same integration.
Token routing guarantees in 1.11.0
The bridge assumes no mode until Dart selects one, then forwards only a matching source: the APNs token in automatic, the plugin-observed FCM token in manual, and the setPushToken() value in external. An early APNs callback can therefore never be flushed as an FCM token. In manual, the raw APNs token is retained only so Firebase can build its APNs-to-FCM mapping, and an explicit setPushToken() is accepted for backward compatibility only. In external, the bridge neither forwards APNs nor configures Firebase.
The router deduplicates an unchanged token, and repeated initialization with the same mode is idempotent. In manual, the plugin observes Firebase refresh notifications without assigning Messaging.messaging().delegate, so existing FlutterFire and application callbacks keep their owner.
Notification delegate coordination
Notification callbacks are independent of the token mode. During plugin registration the bridge installs one UNUserNotificationCenterDelegate coordinator, which standard FlutterFire initialization preserves in its forwarding chain. A Reteno payload reaches native Reteno processing and every forwarding delegate; a non-Reteno payload reaches only the downstream delegate.
The supported topology leaves UNUserNotificationCenter.current().delegate unset until the generated plugins register. Do not assign a FlutterAppDelegate or other FlutterAppLifeCycleProvider as the notification-center delegate before GeneratedPluginRegistrant runs: the coordinator can wrap that provider while FlutterFire wraps the coordinator, producing recursive or duplicate callbacks. A delegate installed after registration must forward its callbacks to the previous one.
Foreground presentation follows the actual delegate order, so configure it on the app's own delegate and verify display on a physical device. Completion handlers are guarded, so the system completion runs once even if a downstream delegate calls it twice.
For add-to-app or a lazily created Flutter engine, register the Reteno plugin before application(_:didFinishLaunchingWithOptions:) returns. A notification response delivered earlier cannot be recovered later.
External FCM token handling
Install the Dart Firebase Messaging package:
flutter pub add firebase_messagingThe helper below replaces the Reteno initialization in section 6 for external mode; keep the Firebase initialization shown there. It gates both the initial and refreshed FCM token on APNs readiness:
import 'dart:async';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:reteno_plugin/reteno.dart';
class RetenoExternalFcmTokenSync {
static const _apnsAttempts = 20;
static const _apnsInterval = Duration(milliseconds: 500);
final Reteno _reteno = Reteno();
StreamSubscription<String>? _subscription;
Future<void> start() async {
await _reteno.initialize(
accessKey: '<your_access_key>',
options: const RetenoInitOptions(
deviceTokenHandlingMode: RetenoDeviceTokenHandlingMode.external,
),
);
_subscription = FirebaseMessaging.instance.onTokenRefresh.listen(sync);
await sync();
}
/// Call again after identifying or changing the Reteno user.
Future<void> sync([String? refreshedToken]) async {
if (!await _waitForApnsToken()) return;
final token = refreshedToken ?? await FirebaseMessaging.instance.getToken();
if (token == null || token.isEmpty) return;
await _reteno.setPushToken(token);
}
Future<bool> requestPermissionAndSync({bool provisional = false}) async {
final granted =
await _reteno.requestPushPermission(provisional: provisional);
if (granted) await sync();
return granted;
}
Future<bool> _waitForApnsToken() async {
for (var attempt = 0; attempt < _apnsAttempts; attempt++) {
if (await FirebaseMessaging.instance.getAPNSToken() != null) return true;
await Future<void>.delayed(_apnsInterval);
}
return false;
}
Future<void> dispose() async {
await _subscription?.cancel();
_subscription = null;
}
}Start it after Firebase initialization:
final tokenSync = RetenoExternalFcmTokenSync();
await tokenSync.start();Keep one instance for the application lifetime and dispose it with the owning service. Each attempt waits up to about ten seconds for APNs, because Firebase cannot issue an FCM token before APNs registration completes; retry on resume if it was still unavailable. Call sync() again after identifying or changing the Reteno user. Pass the FCM registration token, never the APNs token: the native router forwards whatever setPushToken() receives without checking its type.
8. Request permission and verify setup
Request permission at the appropriate point in your user flow. Pass provisional: true to request provisional iOS authorization:
final granted = await Reteno().requestPushPermission(provisional: true);
final issues = await Reteno().diagnose();
if (!granted || issues.isNotEmpty) {
// Keep the user in the setup flow and inspect the reported issues.
}In external mode, use the helper instead so permission and the first FCM synchronization stay in one sequence:
final granted = await tokenSync.requestPermissionAndSync(provisional: true);
final issues = await Reteno().diagnose();APNs registration is asynchronous, so a diagnostic call made right after the prompt can temporarily return REMOTE_NOTIFICATIONS_NOT_REGISTERED.
On iOS, diagnostics report native readiness, the authorization state, and remote-notification registration. An empty list does not validate the access key, Firebase or APNs credentials, App Groups, the service extension, delegate wiring, or external token synchronization.
Finish with an end-to-end test on a physical device:
- Identify the test contact with
setUserAttributes, and inexternalmode synchronize the token again afterwards. - Confirm
diagnose()reports no persistent issues. - Send a test notification from Reteno, not only from Firebase.
- Verify delivery and a rich image handled by
NotificationServiceExtension. - Verify the Reteno foreground, tap, and action streams along with any FlutterFire callbacks the app expects.
For direct APNs, the build signing and the Reteno APNs environment must match, because sandbox and production device tokens are not interchangeable. Reteno uses the production endpoint by default; for an Xcode build, contact Reteno Support to enable sandbox = true on the development mobile app. See Testing APNs delivery.
Upgrading from the previous guide
Keep the Notification Service Extension, App Group, Push Notifications capability, and provider credentials. Remove legacy code that:
- calls
Reteno.start(...)fromAppDelegate; - forwards APNs or FCM tokens directly to native Reteno;
- registers Reteno-only notification or
MessagingDelegateworkarounds; - forwards callbacks to
Reteno.userNotificationServicefrom a notification-center multiplexer, which would make Reteno process each notification twice; - assigns
FlutterAppDelegate/FlutterAppLifeCycleProvideras the notification-center delegate beforeGeneratedPluginRegistrant.
Keep application-specific delegate code that serves other behavior, and make sure a delegate installed later keeps forwarding instead of replacing the chain.
If an earlier iOS build passed customDeviceId despite the old Android-only guidance, review that value first: 1.11.0 now calls the provider and can replace the stored default device ID.
If the migration also changes FlutterFire versions, use the pod-update procedure in section 3 and commit pubspec.lock and Podfile.lock together.
Optional: image carousel push UI
To display the Reteno image carousel, add a NotificationContentExtension, install Reteno 2.7.3 in that target, and follow the iOS Images Carousel guide. That guide configures the ImageCarousel category only and is not a complete GIF setup. Keep this guide's deployment target and Reteno 2.7.3 pin rather than the ones it declares.
NotificationContentExtension is optional. The standard NotificationServiceExtension and shared App Group remain required.
