iOS 27 UIScene migration guide
Keep Customer.io working when your app adopts UIScene or builds with Xcode 27, covering native iOS, Flutter, React Native, and Expo.
This guide explains how to keep Customer.io working when your iOS app adopts UIScene or is built
with the iOS 27 SDK. It covers native iOS, Flutter, React Native, and Expo. Customer.io’s scene
integrations are released, but the React Native and Expo paths also depend on framework releases.
The relevant availability is listed below. Scene support is additive: Customer.io does not replace
your app’s root lifecycle.
Who needs to migrate
The scene launch requirement applies only when both of these are true:
- Your app is built with the iOS 27 SDK, and
- it runs on iOS 27 or later.
A binary built with an earlier SDK keeps launching normally when a user upgrades to iOS 27, so you don’t have to change anything to keep existing installs working.
UIKit can get your scene configuration from either a nonempty UIApplicationSceneManifest or your
AppDelegate’s application(_:configurationForConnecting:options:) method. Your app needs migration
when neither source provides a valid scene configuration. If one of those sources already provides
the configuration, your app has adopted the scene lifecycle.
Existing AppDelegate-only integrations require no Customer.io change while your toolchain still
permits that lifecycle. The scene steps in this guide are additive. Adopt them when you adopt
UIScene or move to the iOS 27 SDK.
Jump to your platform: native iOS · Flutter · React Native · Expo.
Customer.io and framework versions
Use these versions for the scene path:
| Integration | Baseline for this guide |
|---|---|
| Native iOS | Use the current customerio-ios 4.7.6 release. It uses existing public APIs and does not require a separate scene-specific release. |
| Flutter | customer_io 4.4.0 or newer and Flutter 3.44.8 or newer for a scene host. Flutter’s scene APIs start in 3.38 and become the default in 3.41; 3.44.8 is the supported Customer.io scene floor and also contains a separate Xcode 27 build-tool fix. |
| React Native | customerio-reactnative 6.10.0 or newer with React Native 0.88 or newer for acknowledged scene routing. Version 6.9.0 remains compatible through the earlier Linking path. React Native 0.87 and earlier don’t expose the scene URL APIs that the bridge requires. Keep the AppDelegate path until React Native 0.88 is stable. |
| Expo | customerio-expo-plugin 3.9.0 and its required customerio-reactnative 6.9.0 peer with Expo SDK 58 or newer. Expo SDK 58 is currently pre-release; stable Expo SDK 57 apps keep the AppDelegate path. |
Shared AppDelegate and UIScene ownership
The division of responsibility is the same on every platform:
- You keep application-level setup in your AppDelegate. A host that adopts
UIScenecontinues to initialize Customer.io, register APNs tokens, and configure notification handling at the application level. That doesn’t move to a scene. - Your host or framework owns the scene layer: the scene manifest, the SceneDelegate, the root window, and the final navigation.
- Customer.io reports attribution and offers the destination: through the configured native callback (native iOS) or the wrapper/bridge your framework provides (Flutter, React Native, Expo). Without a host handoff, the native SDK may fall back to opening the destination through the system.
- Customer.io does not add its own SceneDelegate and does not choose which of your windows owns a URL. Scene support is additive; it never replaces your app’s root lifecycle.
Each platform section below shows the exact handoff for that framework.
Native iOS
Native iOS uses public APIs in the current customerio-ios 4.7.6 release, so it does not need a
separate scene-specific release. This section assumes you’ve already set up
push notifications and, if you use them,
Live Activities. Your existing APNs, FCM, and
multiple-provider setups are unchanged by scene adoption.
No change needed (AppDelegate-only apps)
If your app uses an app-delegate lifecycle, with no UIApplicationSceneManifest and no
application(_:configurationForConnecting:options:), keep your current integration. Nothing on
this page is required while your toolchain still permits that lifecycle.
Keep initialization in your AppDelegate
A scene-adopting app still initializes Customer.io, registers APNs tokens, and configures
UNUserNotificationCenter at the application level. Leave CustomerIO.initialize,
MessagingPushAPN.initialize (or MessagingPushFCM.initialize), and your notification-center
handling in your AppDelegate. Customer.io does not add a SceneDelegate and does not choose which
of your windows opens a URL. Your app owns the scene manifest, the SceneDelegate, the root window,
and the final navigation.
Route Customer.io destinations with a deep-link callback
Under UIScene, iOS delivers universal links to scene(_:continue:) instead of your AppDelegate.
The SDK’s own fallback still calls application(_:continue:restorationHandler:) directly for
http and https destinations, but it cannot deliver a URL to your SceneDelegate. Configure the
callback below to keep scene-based routing under your control.
If your app uses Customer.io deep-link destinations and wants in-app routing, configure
SDKConfigBuilder.deepLinkCallback where you initialize the SDK. Return true when your app takes
ownership of the URL. Return false to let the SDK try the legacy AppDelegate continuation callback
and then open the URL through the system if the AppDelegate doesn’t handle it. Make the ownership
decision synchronously and dispatch UI work to the main thread when your router requires it. Apps
with no Customer.io destinations, or that intentionally accept the fallback behavior, don’t need a
callback.
let config = SDKConfigBuilder(cdpApiKey: cdpApiKey)
.autoTrackDeviceAttributes(true)
.autoTrackUIKitScreenViews()
.migrationSiteId(siteId)
.deepLinkCallback { url in
guard canHandleDeepLink(url) else { return false }
DispatchQueue.main.async {
handleDeepLink(url)
}
return true
}
CustomerIO.initialize(withConfig: config.build())
Handle URLs and universal links in your SceneDelegate
The system delivers URLs to the scene. Handle cold launches in
scene(_:willConnectTo:options:) and warm opens in scene(_:openURLContexts:) (app-scheme URLs)
and scene(_:continue:) (universal links). Handle app-scheme URLs (URL contexts) and universal
links (user activities) independently.
import UIKit
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = (scene as? UIWindowScene) else { return }
window = UIWindow(windowScene: windowScene)
// Set up your root view controller, then window?.makeKeyAndVisible().
handle(urlContexts: connectionOptions.urlContexts) // cold app-scheme URLs
handle(userActivities: connectionOptions.userActivities) // cold universal links
}
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
handle(urlContexts: URLContexts) // warm app-scheme URLs
}
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
handle(userActivities: [userActivity]) // warm universal links
}
private func handle(urlContexts: Set<UIOpenURLContext>) {
for context in urlContexts {
// Route context.url into your app's navigation.
}
}
private func handle(userActivities: Set<NSUserActivity>) {
for activity in userActivities {
guard let url = activity.webpageURL else { continue }
// Route the universal link into your app's navigation.
}
}
}
Optional: Live Activities
This step applies only if you installed the separate
Live Activities package. Pass each opened URL
through CustomerIO.liveActivities.handleWidgetUrl(_:) exactly once before you route the returned
destination. On a cold launch, call it from scene(_:willConnectTo:options:). On a warm open, call
it from scene(_:openURLContexts:). A nil result means the activity had no destination; an
ordinary URL is returned unchanged.
Add the Live Activities imports at the top of your SceneDelegate file:
import CioDataPipelines
import CioLiveActivities
Then replace the placeholder handle(urlContexts:) method inside SceneDelegate with this
implementation:
private func handle(urlContexts: Set<UIOpenURLContext>) {
for context in urlContexts {
guard let destination = CustomerIO.liveActivities.handleWidgetUrl(context.url) else {
continue
}
_ = handleDeepLink(destination)
}
}
Verify (native iOS)
Run through the cold and warm verification checklist and confirm the single-scene support boundary applies to your app. Detailed native steps live on the deep links and Live Activities pages.
Flutter
This section covers the Flutter SDK (customer_io). If your app keeps the AppDelegate-only
lifecycle, you don’t need to change anything for Customer.io. Follow the steps below only when
your app adopts UIScene or you build with Xcode 27.
When Flutter apps need to migrate
- Flutter’s scene APIs are available from Flutter 3.38 and become the default in Flutter 3.41.
- Customer.io supports scene hosts on Flutter 3.44.8 or newer. This version also contains a separate Flutter build-tool fix required for Xcode 27.
- Use
customer_io4.4.0 or newer on a scene-adopting app. AppDelegate-only apps keep the package’s published Flutter minimum.
Let Flutter own the migration
Follow Flutter’s official UIScene adoption guide for the framework migration. This page only describes the Customer.io-specific result of that migration.
After you migrate, your iOS host has:
-
CioAppDelegateWrapperstill as your application delegate. Keep@main class AppDelegateWithCioIntegration: CioAppDelegateWrapper<AppDelegate> {}. -
The wrapped
AppDelegateconforming toFlutterImplicitEngineDelegate, withGeneratedPluginRegistrantregistration moved out ofdidFinishLaunchingWithOptionsand intodidInitializeImplicitFlutterEngine(_:):import UIKit import Flutter import CioMessagingPushFCM import CioFirebaseWrapper import FirebaseMessaging import FirebaseCore @main class AppDelegateWithCioIntegration: CioAppDelegateWrapper<AppDelegate> {} @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { // Register your Flutter plugins here instead of in didFinishLaunchingWithOptions. func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) } override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { // Customer.io init, APNs, and notification handling stay at the application level. // FirebaseApp.configure() MessagingPushFCM.initialize( withConfig: MessagingPushConfigBuilder().build() ) UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate return super.application(application, didFinishLaunchingWithOptions: launchOptions) } override func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { super.application(application, didRegisterForRemoteNotificationsWithDeviceToken: deviceToken) Messaging.messaging().apnsToken = deviceToken } } -
A standard
UIApplicationSceneManifest. Flutter only auto-migrates an unmodified template AppDelegate. A Customer.io integration customizes that file, so add the standard single-scene manifest manually:<key>UIApplicationSceneManifest</key> <dict> <key>UIApplicationSupportsMultipleScenes</key> <false/> <key>UISceneConfigurations</key> <dict> <key>UIWindowSceneSessionRoleApplication</key> <array> <dict> <key>UISceneClassName</key> <string>UIWindowScene</string> <key>UISceneDelegateClassName</key> <string>FlutterSceneDelegate</string> <key>UISceneConfigurationName</key> <string>flutter</string> <key>UISceneStoryboardFile</key> <string>Main</string> </dict> </array> </dict> </dict> -
FlutterSceneDelegateas your scene delegate. The manifest above uses Flutter’s delegate directly, so you don’t need to create aSceneDelegateclass.If you need a custom scene delegate, subclass
FlutterSceneDelegate:import Flutter import UIKit class SceneDelegate: FlutterSceneDelegate { }Then change the manifest’s
UISceneDelegateClassNameto$(PRODUCT_MODULE_NAME).SceneDelegate. If you write a custom scene delegate that does not subclassFlutterSceneDelegate, it must conform to Flutter’sFlutterSceneLifeCycleProviderand forward every scene lifecycle callback to Flutter. PointUISceneDelegateClassNameat your class in that case too.
How Customer.io routing behaves under UIScene
- Push and in-app destinations route through Flutter automatically when the standard scene
manifest is present and Flutter deep linking is enabled (
FlutterDeepLinkingEnabledis not set tofalse). The plugin installs its SDK deep-link callback during plugin registration, so a cold push tap that arrives beforeCustomerIO.initializeruns is still delivered to your Dart router. - Ordinary links stay owned by Flutter. Customer.io does not claim links it did not issue.
- Live Activity links are unwrapped and attributed by Customer.io, which then offers the
resulting destination to Flutter. In a scene host,
http/httpsdestinations from the SDK also reach the Dart router, so your host router owns unknown-route and browser-opening policy. - Live Activity scene attribution additionally requires the opt-in Live Activities module and a Flutter registrar that supports scene delegates (Flutter 3.44.8 or newer). Without the Live Activities module, push/in-app deep-link routing still works, but scene-delivered Live Activity taps are not attributed. See Set up live notifications.
Advanced: FlutterDeepLinkingEnabled = false
If you set FlutterDeepLinkingEnabled to false, the plugin registers neither its scene
adapter nor its SDK deep-link callback. Your host then owns all Customer.io destinations,
including push taps, and must process Live Activity URLs before your own router handles them.
React Native
These steps apply to hosts using customerio-reactnative 6.10.0 or newer with React Native
0.88 or newer. Version 6.10.0 adds an acknowledged handler so JavaScript can accept or decline a
Customer.io destination before the native SDK decides whether to fall back. If your app still uses
the AppDelegate lifecycle, you don’t need any change here. Your existing integration keeps working.
Deep links (required for scene hosts)
A scene-adopting host owns its SceneDelegate. Four things are required, in order:
-
Install acknowledged deep-link routing before React Native starts. Call this first in
scene(_:willConnectTo:options:), before you start the React Native factory:import customerio_reactnative NativeCustomerIO.configureAcknowledgedSceneDeepLinkRouting() -
Pass cold connection options when React Native starts. This preserves ordinary app-scheme URLs and universal links delivered when the app is terminated:
reactNativeFactory?.startReactNative( withModuleName: "YourApp", in: window, connectionOptions: connectionOptions )If you use Customer.io Live Activities, use the
launchOptionscall in the next section instead. That helper attributes a Live Activity tap and preserves ordinary connection URLs for React Native. -
Forward warm scene URLs and universal links to React Native. These callbacks preserve your existing app links independently of Customer.io destinations:
import React func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) { RCTLinkingManager.scene(scene, openURLContexts: URLContexts) } func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { RCTLinkingManager.scene(scene, continue: userActivity) }If you use Customer.io Live Activities, replace the URL-context callback with the
handleAndRouteWidgetUrlversion in the next section. It passes ordinary URLs through to React Native as well. -
Register the Customer.io deep-link handler before
CustomerIO.initialize. Returntrueafter your router accepts a destination. Returnfalseto use the native fallback.import { useEffect } from 'react'; import { CustomerIO, CioRegion } from 'customerio-reactnative'; useEffect(() => { const subscription = CustomerIO.setDeepLinkHandler(async (url) => { if (!canRouteInApp(url)) { return false; } await routeInApp(url); return true; }); CustomerIO.initialize({ cdpApiKey: 'your-cdp-api-key', region: CioRegion.US, }); return () => subscription.remove(); }, []);
How routing behaves in a scene host:
- Customer.io push, in-app, and inbox destinations are buffered during cold launch and then delivered after the acknowledged handler registers.
- The handler has ten seconds to return a result. Returning
false, throwing, rejecting, missing registration, or timing out makes the SDK try the host AppDelegate. It then sends an app-owned custom scheme to React NativeLinking, or opens another URL through the system. - Keep a
Linkinglistener when your handler might decline an app-owned custom scheme. - A handler that routes after the timeout can cause a second navigation because its late result can’t cancel a fallback that already ran.
- The JavaScript handler owns the routing decision. Return
trueonly after your app accepts the destination.
Live Activities in a scene host (optional)
Only applies if you installed the Live Activities module. In a UIScene host, handle both lifecycle
paths.
Cold scene connection: pass launch options through the wrapper so a cold Live Activity tap is attributed and React Native receives the customer’s destination instead of Customer.io’s internal tracking URL:
import customerio_reactnative
reactNativeFactory?.startReactNative(
withModuleName: "YourApp",
in: window,
launchOptions: NativeLiveActivities.reactNativeLaunchOptions(from: connectionOptions)
)
Warm scene URL opens: report the tap and route each remaining URL through React Native Linking:
import customerio_reactnative
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
for context in URLContexts {
NativeLiveActivities.handleAndRouteWidgetUrl(context.url)
}
}
Do not also pass those URLs to RCTLinkingManager. handleAndRouteWidgetUrl already sends them
through the Customer.io scene bridge, and forwarding them again can deliver the same URL twice.
For an AppDelegate-only (legacy) host, keep the existing NativeLiveActivities.handleWidgetUrl
example. See the React Native live notifications setup.
Unsupported: multiple simultaneous window scenes
This release’s compatibility scope is one simultaneous window scene. Multiple simultaneous React Native window scenes are outside this release. React Native’s URL notification is process-wide, so SDK-published destinations may reach every connected React Native instance rather than one selected window.
Expo
The Customer.io Expo config plugin follows the lifecycle that Expo generates for your project. Expo owns the scene lifecycle, and the plugin configures the native Customer.io bridge to match it.
When the scene path applies
The scene path applies when all of these are true:
- Your app targets Expo SDK 58 or newer, which generates a
UIScene-based iOS project. - You use
customerio-expo-plugin3.9.0 or newer with its requiredcustomerio-reactnative6.9.0 peer.
Below Expo SDK 58, the plugin keeps the AppDelegate path and no scene-related change is required. Your existing setup keeps working while your toolchain still supports that lifecycle.
Deep link readiness
The readiness step depends on how you initialize Customer.io:
- Native auto-initialization (
configinapp.json): register your router first, then callCustomerIO.setDeepLinkRoutingReady(). - JavaScript initialization: register your router first, then call
CustomerIO.initialize(...). Initialization marks scene routing ready automatically, so don’t also callsetDeepLinkRoutingReady().
For native auto-initialization:
- Register your React Native
LinkingURL listener. - Call
CustomerIO.setDeepLinkRoutingReady().
import { Linking } from 'react-native';
import { CustomerIO } from 'customerio-reactnative';
// Register your URL listener first...
Linking.addEventListener('url', ({ url }) => {
// your app's navigation
});
// ...then signal that URL handling is ready.
CustomerIO.setDeepLinkRoutingReady();
- Apps without Expo Router register their
Linkinglistener first, then callsetDeepLinkRoutingReady(). - Expo Router apps must not add a second navigating
Linkinglistener. Expo Router already owns that subscription. CallsetDeepLinkRoutingReady()once your router is ready.
Customer.io buffers cold-start notification destinations and delivers them to your Linking listener as url events after you signal readiness. They do not come back from Linking.getInitialURL(). If readiness isn’t signaled within ten seconds, Customer.io falls back to opening the destination through the system.
Live Activity links on the scene path
The scene path removes the AppDelegate Live Activity URL handling the plugin used to inject. Live Activity attribution therefore moves to JavaScript, so the +native-intent.tsx (or central Linking) step below is required in the same upgrade if you use Live Activities.
Process each incoming URL through CustomerIO.liveActivities.handleWidgetUrl(path) exactly once. The helper reports the opened event, then returns the destination to route. An ordinary URL passes through unchanged, and a Customer.io tracking URL with no destination returns null.
Expo Router: process Live Activity URLs in a top-level app/+native-intent.tsx:
import { CustomerIO } from 'customerio-reactnative';
export async function redirectSystemPath({ path }: { path: string }) {
return CustomerIO.liveActivities.handleWidgetUrl(path);
}
If the helper returns null, Expo Router performs no redirect and keeps the current path.
Apps without Expo Router: apply the same helper exactly once in your central initial-URL and URL-subscription pipeline.
Cold and warm verification checklist
For each integration you use, verify:
- The app launches from a terminated (cold) state.
- A notification tap reaches the intended destination.
- App-scheme links and universal links work in both cold and warm states.
- Live Activity taps report attribution and route the destination once (if you use Live Activities).
- Non-Customer.io notifications and any third-party routers still receive their callbacks.
- Your existing APNs, FCM, and multiple-provider setups are unchanged by scene adoption. See the iOS push setup and push certificates pages rather than re-configuring anything here.
An iOS 16 or newer Simulator on macOS 13 or newer can receive remote notifications from the APNs sandbox when its physical Mac has Apple silicon or an Apple T2 Security Chip. Use a physical device for production APNs or device-specific behavior. A simulator inside a virtualized macOS runner may not receive an APNs token.
Single-scene support boundary
The supported compatibility scope is one simultaneous window scene. That covers the vast majority of iPhone and iPad apps.
Multiple simultaneous window scenes (multi-window ownership, for example, several windows of the same app open side by side on iPad or macOS) are out of scope for this release and aren’t yet supported by the SDK. Adopt the single-scene path described here; don’t rely on multi-window scene ownership.
Troubleshooting
A deep link opens in the browser (or the system) instead of your app. The host handoff isn’t
configured, so the SDK used its system-open fallback. Configure the platform’s callback: native
iOS uses SDKConfigBuilder.deepLinkCallback; the framework platforms use their bridge/wrapper as
described in each section.
A notification tap does nothing on a cold launch. Make sure your scene handler reads the
launch URL/user activity from the connection options in scene(_:willConnectTo:options:) (or the
framework equivalent), not only the warm-open callbacks.
A Live Activity tap is attributed twice, or not at all. Route each opened URL through the Live Activity handler exactly once before your own routing. Don’t also forward the same URL to a second handler.
Universal links don’t reach your app after migration. Universal-link ownership moves to the
scene callback (scene(_:continue:) on native iOS). Confirm your associated-domains setup is
intact and your scene handler processes user activities.