Skip to main content
Build Live Notifications on Android by sending a structured payload over OneSignal push and updating a single notification in place from your NotificationServiceExtension. The pattern mirrors iOS Live Activities, but it is implemented in your app code rather than provided by OneSignal as a managed feature. Use it for progress bars, sports scores, ride status, or any other content that should update inside one persistent notification instead of stacking new pushes. Live Notifications require the user to have push notifications enabled.

Requirements

  • Latest OneSignal Android SDK integrated in your app.
  • The end user has granted push notification permission.
  • A NotificationServiceExtension in your app to intercept and render Live Notifications. See Service extensions for background.

How it works

OneSignal is the transport. Your app code defines the schema and renders the UI. The flow:
  1. Your backend calls the Create message API with a collapse_id and a structured live_notification object inside data.
  2. OneSignal delivers the push to the device.
  3. Your NotificationServiceExtension intercepts every push, looks for the live_notification key, and dispatches based on its event (start, update, or end).
  4. Your code calls NotificationManager.notify(id, …) with the same Android notification ID each time, which updates the existing notification rather than posting a new one.
The live_notification payload, its field names, and the start / update / end event values are a convention introduced by this guide. They are not enforced by OneSignal. You can rename or restructure anything as long as your service extension and your sending backend agree.

Live Notifications vs. standard push

Unlike regular push notifications, which post a new notification each time, Live Notifications use one persistent notification updated over time. Two things make this work:
  • collapse_id on the API side keeps later messages tied to the same logical notification. See Collapse ID (mobile push).
  • A stable Android notification ID on the device side lets NotificationManager.notify(id, …) replace the visible notification’s contents instead of stacking a new one.

Implementation

1. Add a Notification Service Extension

Create a class that implements INotificationServiceExtension. The class below is complete and runnable: it creates the notification channel on first use, parses the live_notification payload, dispatches by event, and renders a progress notification as an example.
NotificationServiceExtension.kt
package com.onesignal.sample.android

import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.graphics.BitmapFactory
import android.os.Build
import androidx.annotation.Keep
import androidx.core.app.NotificationCompat
import com.onesignal.notifications.INotificationReceivedEvent
import com.onesignal.notifications.INotificationServiceExtension
import org.json.JSONObject
import java.util.logging.Logger

@Keep
class NotificationServiceExtension : INotificationServiceExtension {

    companion object {
        private val logger = Logger.getLogger(NotificationServiceExtension::class.java.name)

        // Map each Live Notification `key` to a stable Android notification ID.
        // Reusing the same ID is what lets `notify(...)` update the visible
        // notification instead of posting a new one.
        private val keyMap = mapOf(
            "progress" to 1001,
            // Add additional Live Notification keys here.
        )

        private const val PROGRESS_CHANNEL_ID = "live_notifications_progress"

        @Volatile
        private var notificationChannelsCreated = false
    }

    override fun onNotificationReceived(event: INotificationReceivedEvent) {
        val context = event.context
        val notificationManager =
            context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
                ?: run {
                    logger.warning("NotificationManager not available.")
                    return
                }

        if (!notificationChannelsCreated) {
            createNotificationChannels(notificationManager)
            notificationChannelsCreated = true
        }

        val payload = event.notification.additionalData?.optJSONObject("live_notification")
        if (payload == null) {
            // Not a Live Notification. Let OneSignal display the push normally.
            return
        }

        // Render the Live Notification ourselves; skip OneSignal's default display.
        event.preventDefault()
        handleLiveNotification(payload, notificationManager, context)
    }

    private fun handleLiveNotification(
        payload: JSONObject,
        notificationManager: NotificationManager,
        context: Context,
    ) {
        val key = payload.optString("key").takeIf { it.isNotEmpty() } ?: run {
            logger.warning("Live Notification missing 'key'.")
            return
        }
        val notificationId = keyMap[key] ?: run {
            logger.warning("Unknown Live Notification key: $key")
            return
        }
        val updates = payload.optJSONObject("event_updates") ?: JSONObject()

        when (val eventType = payload.optString("event")) {
            "start", "update" -> when (key) {
                "progress" -> updateProgressNotification(
                    updates, notificationId, context, notificationManager,
                )
                // Route additional keys to their builders here.
            }
            "end" -> notificationManager.cancel(notificationId)
            else -> logger.warning("Unknown Live Notification event: $eventType")
        }
    }

    private fun updateProgressNotification(
        updates: JSONObject,
        notificationId: Int,
        context: Context,
        notificationManager: NotificationManager,
    ) {
        val currentProgress = updates.optInt("current_progress", 0)

        val builder = NotificationCompat.Builder(context, PROGRESS_CHANNEL_ID)
            .setContentTitle("Download in progress")
            .setContentText("$currentProgress% complete")
            .setSmallIcon(android.R.drawable.ic_media_play)
            .setLargeIcon(
                BitmapFactory.decodeResource(
                    context.resources,
                    android.R.drawable.ic_dialog_info,
                ),
            )
            .setOngoing(true)
            .setOnlyAlertOnce(true)
            .setProgress(100, currentProgress, false)
            .setAutoCancel(false)

        notificationManager.notify(notificationId, builder.build())
    }

    private fun createNotificationChannels(notificationManager: NotificationManager) {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return

        val progressChannel = NotificationChannel(
            PROGRESS_CHANNEL_ID,
            "Progress Live Notifications",
            NotificationManager.IMPORTANCE_LOW,
        ).apply {
            description = "Progress updates that replace themselves in place."
            setShowBadge(false)
            enableVibration(false)
            setSound(null, null)
        }
        notificationManager.createNotificationChannel(progressChannel)
    }
}
The @Keep annotation prevents R8 or ProGuard from stripping or renaming this class when minification is enabled. See Service extensions for more on writing service extensions.
Channels for Live Notifications should be low importance with badges, vibration, and sound disabled so frequent updates don’t alert the user. See Android notification categories.

2. Register the extension in the Android Manifest

Add the com.onesignal.NotificationServiceExtension meta-data tag inside <application>, pointing to the fully qualified name of your class.
<meta-data android:name="com.onesignal.NotificationServiceExtension"
           android:value="com.onesignal.sample.android.NotificationServiceExtension" />
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <application
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:name=".MainApplication"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.OneSignalAndroidSample"
        tools:targetApi="31">
        <meta-data android:name="com.onesignal.NotificationServiceExtension"
            android:value="com.onesignal.sample.android.NotificationServiceExtension" />
        <activity
            android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>

3. Define your Live Notification payload

Each Live Notification carries a live_notification object inside the push’s data field. The schema is a convention you control. The shape used in this guide:
PropertyRequiredDescription
keyYesIdentifies which Live Notification UI to render and which Android notification ID to reuse.
eventYesLifecycle event: start, update, or end.
event_attributesNoStatic data passed once on start. Use it for fields that don’t change for the life of the notification (team names, order ID, etc.).
event_updatesNoDynamic data passed on start and every update. The shape is whatever your builder consumes (scores, percentages, ETAs).
Example payload (start of a sports score Live Notification):
{
  "key": "celtics-vs-lakers",
  "event": "start",
  "event_attributes": {
    "homeTeam": "Celtics",
    "awayTeam": "Lakers",
    "game": "Finals Game 1"
  },
  "event_updates": {
    "quarter": 1,
    "homeScore": 0,
    "awayScore": 0
  }
}

4. Handle the lifecycle events

Your service extension dispatches on the event field. The example code above does this in handleLiveNotification.
EventDescriptionRequired fields
startCreates the Live Notification with static and dynamic data.event_attributes, event_updates
updateUpdates the existing Live Notification with new dynamic data.event_updates
endRemoves the Live Notification by calling NotificationManager.cancel(id).None
Always reuse the same Android notification ID for a given key across start, update, and end. Using a new ID posts a separate notification instead of updating the existing one.

Send a Live Notification

Send start, update, and end events with the Create message API. Every request must:
  • Set the same collapse_id so OneSignal collapses later messages onto the first one. The ID must be unique to that specific Live Notification instance.
  • Target the same set of users (typically with include_aliases.external_id).
  • Use isAndroid: true to restrict delivery to Android push subscriptions.

Start

Send event: "start" to create the Live Notification. Initialize both static (event_attributes) and dynamic (event_updates) data.
Start
curl -X POST "https://api.onesignal.com/notifications" \
  -H "Authorization: Key YOUR_APP_API_KEY" \
  -H "Content-Type: application/json; charset=utf-8" \
  -d '{
    "app_id": "YOUR_APP_ID",
    "isAndroid": true,
    "collapse_id": "UNIQUE_LIVE_NOTIFICATION_ID",
    "data": {
      "live_notification": {
        "key": "progress",
        "event": "start",
        "event_attributes": {},
        "event_updates": {
          "current_progress": 0
        }
      }
    },
    "headings": { "en": "Start" },
    "contents": { "en": "Starting Live Notification" },
    "include_aliases": { "external_id": ["EID1", "EID2"] },
    "target_channel": "push"
  }'

Update

Send event: "update" with new event_updates data. You can update as many times as you like after start.
Update
curl -X POST "https://api.onesignal.com/notifications" \
  -H "Authorization: Key YOUR_APP_API_KEY" \
  -H "Content-Type: application/json; charset=utf-8" \
  -d '{
    "app_id": "YOUR_APP_ID",
    "isAndroid": true,
    "collapse_id": "UNIQUE_LIVE_NOTIFICATION_ID",
    "data": {
      "live_notification": {
        "key": "progress",
        "event": "update",
        "event_updates": {
          "current_progress": 80
        }
      }
    },
    "headings": { "en": "Update" },
    "contents": { "en": "Updating Live Notification" },
    "include_aliases": { "external_id": ["EID1", "EID2"] },
    "target_channel": "push"
  }'

End

Send event: "end" to dismiss the Live Notification. The service extension calls NotificationManager.cancel(id) to remove it from the user’s device.
End
curl -X POST "https://api.onesignal.com/notifications" \
  -H "Authorization: Key YOUR_APP_API_KEY" \
  -H "Content-Type: application/json; charset=utf-8" \
  -d '{
    "app_id": "YOUR_APP_ID",
    "isAndroid": true,
    "collapse_id": "UNIQUE_LIVE_NOTIFICATION_ID",
    "data": {
      "live_notification": {
        "key": "progress",
        "event": "end"
      }
    },
    "headings": { "en": "Ending" },
    "contents": { "en": "Ending Live Notification" },
    "include_aliases": { "external_id": ["EID1", "EID2"] },
    "target_channel": "push"
  }'

FAQ

Are Android Live Notifications a managed OneSignal feature?

No. They are a pattern you implement in your NotificationServiceExtension on top of OneSignal push and Android’s NotificationManager. OneSignal does not track Live Notification state, manage update lifecycles, or expose Live Notifications in the dashboard. The schema and event names in this guide are conventions you can change.

Why do I need both collapse_id and a stable Android notification ID?

They solve different problems. collapse_id deduplicates messages on the OneSignal side and APNs/FCM side so the device only processes the latest one if updates arrive in a burst. The stable Android notification ID is what NotificationManager.notify(id, …) uses to replace the visible notification’s contents instead of posting a new one. You need both.

How often can I send updates?

Frequent updates are fine, but use a low importance channel (IMPORTANCE_LOW) with badges, vibration, and sound disabled. High importance with frequent updates will alert the user every time and is the most common reason support tickets get opened for this pattern.

What happens if the user kills the app or device reboots?

The Android notification persists on the lock screen and notification shade until your app cancels it or the user dismisses it. The service extension only runs when a push arrives, so any in-memory state (like progress) must be encoded in event_updates, not held in app memory.

Can I do this on iOS too?

iOS has a managed equivalent: Live Activities. It uses ActivityKit and is built into OneSignal as a first-class feature with its own Start and Update APIs. Use Live Activities on iOS rather than this pattern.

Service extensions

Intercept and customize push notifications before display on Android and iOS.

Create message API

The API used to send the start, update, and end pushes for a Live Notification.

Live Activities (iOS)

The managed iOS equivalent built on ActivityKit.

Android notification categories

Configure channels so frequent Live Notification updates don’t alert the user.