GitHub project

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.

Apple beta software

Xcode 27 and iOS 27 are currently beta releases. Recheck Apple’s current release notes before shipping with the iOS 27 SDK.

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:

IntegrationBaseline for this guide
Native iOSUse the current customerio-ios 4.7.6 release. It uses existing public APIs and does not require a separate scene-specific release.
Fluttercustomer_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 Nativecustomerio-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.
Expocustomerio-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 UIScene continues 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.

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())

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_io 4.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:

  • CioAppDelegateWrapper still as your application delegate. Keep @main class AppDelegateWithCioIntegration: CioAppDelegateWrapper<AppDelegate> {}.

  • The wrapped AppDelegate conforming to FlutterImplicitEngineDelegate, with GeneratedPluginRegistrant registration moved out of didFinishLaunchingWithOptions and into didInitializeImplicitFlutterEngine(_:):

    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>
  • FlutterSceneDelegate as your scene delegate. The manifest above uses Flutter’s delegate directly, so you don’t need to create a SceneDelegate class.

    If you need a custom scene delegate, subclass FlutterSceneDelegate:

    import Flutter
    import UIKit
    
    class SceneDelegate: FlutterSceneDelegate {
    }

    Then change the manifest’s UISceneDelegateClassName to $(PRODUCT_MODULE_NAME).SceneDelegate. If you write a custom scene delegate that does not subclass FlutterSceneDelegate, it must conform to Flutter’s FlutterSceneLifeCycleProvider and forward every scene lifecycle callback to Flutter. Point UISceneDelegateClassName at 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 (FlutterDeepLinkingEnabled is not set to false). The plugin installs its SDK deep-link callback during plugin registration, so a cold push tap that arrives before CustomerIO.initialize runs 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/https destinations 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.

Warning

Don’t set the native SDKConfigBuilder.deepLinkCallback in a Flutter host. The Flutter plugin owns the SDK’s single callback slot and uses it to deliver Customer.io destinations to Dart.

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.

React Native framework requirement

React Native 0.87 and earlier don’t expose the scene URL APIs that the Customer.io bridge requires. Keep the AppDelegate lifecycle and its existing deep-link integration until React Native 0.88 is available as a stable release. Installing customerio-reactnative 6.10.0 alone doesn’t add those framework APIs.

A scene-adopting host owns its SceneDelegate. Four things are required, in order:

  1. 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()
  2. 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 launchOptions call in the next section instead. That helper attributes a Live Activity tap and preserves ordinary connection URLs for React Native.

  3. 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 handleAndRouteWidgetUrl version in the next section. It passes ordinary URLs through to React Native as well.

  4. Register the Customer.io deep-link handler before CustomerIO.initialize. Return true after your router accepts a destination. Return false to 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 Native Linking, or opens another URL through the system.
  • Keep a Linking listener 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 true only after your app accepts the destination.

Backward-compatible Linking path

Version 6.9.0 scene integrations can keep NativeCustomerIO.configureSceneDeepLinkRouting() and a Linking listener registered before CustomerIO.initialize. That path still buffers cold URLs, but a Linking event can’t report whether JavaScript handled it. The SDK therefore can’t safely fall back after publishing the event. Prefer the acknowledged handler for new integrations.

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.

Expo framework requirement

Expo SDK 58 is currently pre-release. Stable Expo SDK 57 apps keep the AppDelegate path and don’t need a scene-related Customer.io change. Apply the scene instructions below after Expo SDK 58 is available as a stable release.

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-plugin 3.9.0 or newer with its required customerio-reactnative 6.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.

Note

Expo owns the scene manifest, SceneDelegate, root window, and final navigation. Customer.io reports attribution and hands your app the destination to route. Scene support is additive. Customer.io never replaces your app’s root lifecycle.

The readiness step depends on how you initialize Customer.io:

  • Native auto-initialization (config in app.json): register your router first, then call CustomerIO.setDeepLinkRoutingReady().
  • JavaScript initialization: register your router first, then call CustomerIO.initialize(...). Initialization marks scene routing ready automatically, so don’t also call setDeepLinkRoutingReady().

For native auto-initialization:

  1. Register your React Native Linking URL listener.
  2. 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 Linking listener first, then call setDeepLinkRoutingReady().
  • Expo Router apps must not add a second navigating Linking listener. Expo Router already owns that subscription. Call setDeepLinkRoutingReady() 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.

handleDeeplinkInKilledState is legacy

handleDeeplinkInKilledState is the legacy AppDelegate killed-state workaround for Expo SDK 53–57. It is not injected for the Expo SDK 58+ scene lifecycle, because scene routing replaces it. Setting it on a scene project has no effect.

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.

Warning

Don’t call handleWidgetUrl from both +native-intent.tsx and a Linking listener. Processing the same tracking URL twice reports two opened events.

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.

Updated August 28, 2026