GitHub project

Set up live notifications

Updated August 19, 2026

How it works

Live notifications show information that changes over time in places your customers already look: the iOS Lock Screen and the Android notification panel. On iOS they’re built on Apple’s Live Activities; on Android, they’re built on Google’s Live Updates.

The SDK includes two built-in templates that both native SDKs render for you—a multi-step tracker and a countdown timer—so you don’t need to write a platform-specific UI for either one. You can also define a single custom type and render it yourself.

Four actors drive every live notification:

  • You enable the notification types your app uses, and start notifications from JavaScript when something happens in your app.
  • The SDK registers the push tokens each notification needs. It also reports to Customer.io when your app starts or ends a notification, and when a customer dismisses one. Updates your app makes on the device aren’t reported.
  • Your server starts, updates, and ends notifications by calling the API. It never talks to APNs or FCM directly.
  • Customer.io turns each API call into a push, using the tokens the SDK registered, and records every start, update, and end as a delivery.

iOS normally requires a fair amount of Xcode work for live notifications. The Expo config plugin does that work for you at prebuild: it adds the widget extension target, sets the NSSupportsLiveActivities key, adds the iOS pod subspec, and injects the AppDelegate code that attributes taps. Android needs no build-time setup at all.

For the concepts behind the feature and the operations your server calls, see the live notifications overview.

Prerequisites

Live notifications use the push credentials your app already has in Customer.io. Before you begin:

  • Set up push notifications for your app.
  • Use Expo SDK 53 or later.
  • You must identify your app user before you can start a notification. The SDK holds token registrations until you identify a person, and the API rejects requests that target anonymous profiles.
  • On iOS, target iOS 16.2 or later. Starting a notification from your server (push-to-start) requires iOS 17.2.
  • On Android, request POST_NOTIFICATIONS on Android 13 and later. To get Android 16’s promoted Live Updates treatment, also declare POST_PROMOTED_NOTIFICATIONS in your manifest. Without it, live notifications still render as ongoing notifications.

Configure the plugin

How you turn on live notifications depends on how you initialize the SDK.

Add a liveNotifications object under config in your app.json. Its presence enables the feature—you don’t also need liveNotifications.enabled.

{
   "expo": {
      "plugins": [
         [
            "customerio-expo-plugin",
            {
               "config": {
                  "cdpApiKey": "<CDP_API_KEY>",
                  "region": "us",
                  "liveNotifications": {
                     "types": [
                        "io.customer.livenotifications.segments",
                        "io.customer.livenotifications.countdowntimer"
                     ]
                  }
               }
            }
         ]
      ]
   }
}

When you initialize the SDK from JavaScript, there’s no config block for the plugin to read, so turn the build-time setup on explicitly with enabled:

{
   "expo": {
      "plugins": [
         [
            "customerio-expo-plugin",
            {
               "liveNotifications": {
                  "enabled": true
               }
            }
         ]
      ]
   }
}

Then pass your notification types to CustomerIO.initialize in your app’s code, the same way you would in React Native.

Run npx expo prebuild after you change these options, so the plugin regenerates the native projects. You only need --clean when you add or rename customWidget or customRenderer source files—once the widget target exists, the plugin re-syncs file contents but doesn’t discover new files.

To turn the feature off on either path, set enabled: false in the top-level liveNotifications block—it wins over everything else.

Omitting types enables every built-in template, which is what an app that picks its types at runtime needs. To register only your own custom type, set types to an empty array alongside customType. Unrecognized identifiers are ignored with a warning, so a template added in a newer SDK won’t break your build.

Where each option goes

The plugin reads two separate liveNotifications blocks, and the distinction matters:

OptionWhere it goesWhy
types, customTypeUnder configApplied when the SDK initializes.
enabled, branding, customWidget, customRendererTop level, next to configConsumed at build time. Keeping them outside config is what makes them work on both initialization paths.

Branding

Branding applies to the built-in templates. The plugin compiles it into the generated iOS widget and passes it to the Android SDK, so one block covers both platforms:

"liveNotifications": {
   "branding": {
      "accentColorHex": "#1B5E20",
      "backgroundColorHex": "#FFFFFF",
      "textColorHex": "#1A1A1A",
      "logo": "./assets/brand-logo.png"
   }
}

accentColorHex tints the Android notification and fills the iOS progress bar. backgroundColorHex and textColorHex style the generated iOS widget only. Color values must be six-digit #RRGGBB—anything else fails prebuild.

logo takes either a project-relative path or an http(s) URL. The plugin copies a local path into your Android drawable resources and the iOS widget asset catalog. A URL is downloaded at render time on Android and isn’t supported on iOS, where the widget is compiled ahead of time.

Manual initialization needs branding in both places

If you initialize the SDK from JavaScript, set branding in the plugin options and pass it to CustomerIO.initialize. The plugin’s value is what it compiles into the iOS widget; the runtime value is what Android applies. On that path the plugin generates no initialization code, so it can’t forward the value for you.

Render a custom type

To send data that Customer.io’s built-in templates don’t support by default, you can name your own type with customType and hand the plugin the source files that render it. You don’t need a widget target of your own—the plugin compiles your SwiftUI into the one it generates.

{
   "config": {
      "liveNotifications": {
         "types": ["io.customer.livenotifications.segments"],
         "customType": "com.myapp.rideshare"
      }
   },
   "liveNotifications": {
      "customWidget": {
         "sourceFile": "./ios-widgets/RideshareLiveActivity.swift",
         "structName": "RideshareLiveActivity"
      },
      "customRenderer": {
         "sourceFile": "./android-renderers/RideshareLiveNotification.kt",
         "className": "RideshareLiveNotificationCallback"
      }
   }
}
  • customWidget.structName is the Widget struct the plugin instantiates in the generated WidgetBundle. Render the SDK’s CIOCustomAttributes in it, and apply .cioWidgetUrl(context.state.cioMetadata) to your Lock Screen view and Dynamic Island regions—the plugin doesn’t wire tap attribution into your SwiftUI, so without it taps on your custom notification aren’t attributed and don’t route their deep link.
  • customRenderer.className is a CustomerIOLiveNotificationsCallback class with a no-argument constructor. It receives the same data map your app sends.

Configure both. Setting only one leaves the notification without a renderer on the other platform. Each option also accepts an array of paths when your renderer spans several files.

Then start it with the Custom template:

await CustomerIO.liveActivities.start({
  type: LiveActivityTemplate.Custom,
  data: { driver: 'Sam', vehicle: 'Blue Prius', minutesAway: '4' },
});