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. 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, and adds the iOS pod subspec. On the AppDelegate path (Expo SDK 53–57), it also injects the AppDelegate code that attributes taps. On the scene lifecycle (Expo SDK 58+), that AppDelegate tap handling is removed and attribution moves to JavaScript. See Live Activity links on the scene path. 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.
Live Activity links on the scene path (Expo SDK 58+)
If you build with Expo SDK 58 or newer (using customerio-expo-plugin 3.9.0 and
customerio-reactnative 6.9.0), your iOS project uses the scene lifecycle. On this path the plugin
no longer injects the AppDelegate Live Activity URL handling it used to add, so Live Activity
attribution moves to JavaScript. Add this step in the same upgrade. Without it, taps on a Live
Activity render but don’t report an opened metric or route their destination.
Process each incoming URL through CustomerIO.liveActivities.handleWidgetUrl(path) exactly once. The helper reports the opened event, then returns the URL your app should route. An ordinary URL passes through unchanged, and a Customer.io tracking URL with no destination returns null.
Expo Router
Process Live Activity URLs once 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.
Expo Router owns navigation after redirectSystemPath returns the destination.
Without Expo Router
Apply the same helper exactly once in your central initial-URL and URL-subscription pipeline. This
differs from Customer.io notification destinations: a cold Live Activity tap is the system launch
URL, while a cold notification destination is buffered by the Customer.io bridge and emitted as a
url event after you signal readiness.
import { Linking } from 'react-native';
import { CustomerIO } from 'customerio-reactnative';
async function routeUrl(incomingUrl: string) {
const destination = await CustomerIO.liveActivities.handleWidgetUrl(incomingUrl);
if (destination) {
// navigate to destination with your router
}
}
// initial URL (cold start)
Linking.getInitialURL().then((url) => {
if (url) routeUrl(url);
});
// subsequent URLs (warm)
Linking.addEventListener('url', ({ url }) => routeUrl(url));Below Expo SDK 58, the plugin keeps injecting the AppDelegate tap handling and no JavaScript change is required. For the full cross-SDK picture, see the iOS 27 UIScene migration guide.
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' },
});