Set up live notifications
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
The Customer.io operation that adds or updates a person. When you identify a person, Customer.io either adds a person if they don't exist in your workspace, or updates them if they do. Before you identify someone (by their email address or an ID), you can track them anonymously.
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_NOTIFICATIONSon Android 13 and later. To get Android 16’s promoted Live Updates treatment, also declarePOST_PROMOTED_NOTIFICATIONSin 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.
Automatic initialization
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"
]
}
}
}
]
]
}
}Manual initialization
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:
| Option | Where it goes | Why |
|---|---|---|
types, customType | Under config | Applied when the SDK initializes. |
enabled, branding, customWidget, customRenderer | Top level, next to config | Consumed 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.
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.structNameis theWidgetstruct the plugin instantiates in the generatedWidgetBundle. Render the SDK’sCIOCustomAttributesin 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.classNameis aCustomerIOLiveNotificationsCallbackclass with a no-argument constructor. It receives the samedatamap 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' },
});