> ## Documentation Index
> Fetch the complete documentation index at: https://documentation.onesignal.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Android Live Update notifications

> Build Android Live Updates on top of OneSignal push. This pattern mirrors iOS Live Activities by routing a structured payload through a Notification Service Extension to update one persistent, promoted notification in place.

Build Live Updates on Android by sending a structured payload over OneSignal push and updating a single notification in place from your `NotificationServiceExtension`. Use it for ongoing, user-initiated activities such as a delivery in progress, a live match score, a ride status, or a file upload.

<Note>
  Live Updates are Android's version of iOS [Live Activities](./live-activities). Android documents them as [Live Updates](https://developer.android.com/develop/ui/views/notifications/live-update), while the underlying APIs call them promoted ongoing notifications.

  Unlike iOS Live Activities, this is a pattern you implement in your app rather than a managed OneSignal feature.
</Note>

***

## When to use Live Updates

Send a Live Update when the user needs to glance at changing information without opening anything.

On Android 16 QPR1 and higher, a promoted Live Update sits at the top of the notification drawer, takes prominent placement on the lock screen, and appears as a chip in the status bar while the shade is closed. Samsung routes Live Updates into the Now Bar, which also surfaces on the always-on display. That persistent, glanceable presence is the whole benefit. An ordinary ongoing notification sits in the shade and stays invisible until the user pulls it down.

Android defines three criteria for the pattern. A Live Update should represent an activity that is **ongoing** (actively in progress, with a distinct start and end), **user-initiated** (the user explicitly started it), and **time-sensitive** (worth their attention for the whole duration). See Google's [usage criteria](https://developer.android.com/develop/ui/views/notifications/live-update#usage-criteria) for the full guidance.

**Good fits.** Active navigation, an ongoing phone call, a ride in progress, a food delivery, a workout, a file upload, or a live match the user chose to follow. For an activity the user opted into rather than started, such as a game they asked to track, include an unpin action so they can stop following it.

**Poor fits.** Anything the user didn't start, anything without an end state, and anything you would send to a segment. Google names ads, promotions, chat messages, alerts, upcoming calendar events, and shortcuts to app features as inappropriate uses. Something that already finished belongs in a standard notification.

**What it costs you.** An extra permission, a set of eligibility rules every notification must satisfy, and a version branch in your extension. Promotion only appears on Android 16 QPR1 and higher, so expect most of your audience to see the unpromoted rendering for now.

<Warning>
  Users can demote a Live Update back to a standard notification, and they can turn promotion off for your app entirely. Promoting content that isn't an ongoing, user-initiated activity is a fast way to lose the surface, or to lose notification permission altogether.
</Warning>

If your content doesn't need to be glanceable, such as a single completion notice or an alert the user reads once, send a standard push instead.

***

## Requirements

* [OneSignal Android SDK](./mobile-sdk-setup) 5.1.14 or newer integrated in your app. Version 5.1.14 added the `preventDefault(discard)` overload this pattern relies on.
* The end user has granted push notification permission. Live Updates are delivered over push, so a user who has not granted permission never receives them.
* A `NotificationServiceExtension` in your app to intercept and render Live Updates. See [Service extensions](./service-extensions#android-notification-service-extension) for background.
* Your app built against `compileSdk 36` or higher. This is required to compile `Notification.ProgressStyle` and `setShortCriticalText`.
* A device running Android 16 QPR1 (API 36.1) or higher for the promoted "Live Update" treatment: prominent placement plus a [status chip](https://developer.android.com/develop/ui/views/notifications/live-update#status-chips) when you set short critical text. On Android 16.0 and lower the notification still updates in place, just without promotion.

<Note>
  Two API levels are involved. `Notification.ProgressStyle`, `setShortCriticalText`, `hasPromotableCharacteristics()`, and `canPostPromotedNotifications()` were added in API 36. The promotion setter `setRequestPromotedOngoing` and the `EXTRA_REQUEST_PROMOTED_ONGOING` constant were added in API 36.1, so at `compileSdk 36` you request promotion with the extra's string key instead. Step 1 explains how the sample code handles this.
</Note>

***

## 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](/reference/create-message) 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.

<Note>
  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.
</Note>

***

## Live Updates vs. standard push

Unlike regular push notifications, which post a new notification each time, Live Updates use one persistent notification updated over time. Two things make this work:

* **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. This is what produces the single updating notification.
* **`collapse_id` on the API side** ties every message to the same logical notification so a device that was offline receives only the newest update instead of the whole backlog. See [Collapse ID (mobile push)](./push#collapse-id-mobile-push).

***

## Implementation

### 1. Add a Notification Service Extension

Create a class that implements `INotificationServiceExtension`. Place it at `app/src/main/java/<your-package-path>/NotificationServiceExtension.kt`, where `<your-package-path>` matches your app's package (for example, `com/yourcompany/yourapp/`).

The class below is complete and runnable. It creates the notification channel on first use, parses the `live_notification` payload, dispatches by event, honors dismissals, and renders a progress notification. Replace `com.onesignal.sample.android` in the `package` declaration with your app's package name (for example, `com.yourcompany.yourapp`); the same fully qualified name must appear in the Manifest entry in Step 2. The `@Keep` annotation is required to stop R8 or ProGuard from stripping or renaming the class when minification is enabled. See [Service extensions](./service-extensions#android-notification-service-extension) for more on writing service extensions.

Two details are worth noticing before you read it. The sample branches on the OS version, so devices on API 36 and above get the promoted Live Update treatment while everything below that gets an ordinary ongoing progress notification. Keep both paths unless your `minSdk` is 36 or higher, because the Live Update APIs do not exist on older devices and calling them there throws at runtime. The sample also records when a user dismisses a Live Update and stops updating it, which Android requires and which this push-driven pattern would otherwise violate on the next update.

```kotlin NotificationServiceExtension.kt theme={null}
package com.onesignal.sample.android

import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.graphics.drawable.Icon
import android.os.Build
import android.os.Bundle
import androidx.annotation.Keep
import androidx.annotation.RequiresApi
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 Update `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 Update keys here.
        )

        private const val PROGRESS_CHANNEL_ID = "live_updates_progress"

        // Backstop for a missed `end` push. Set it a little longer than your
        // longest expected activity.
        private const val ACTIVITY_TIMEOUT_MS = 4 * 60 * 60 * 1000L

        @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 Update. Let OneSignal display the push normally.
            return
        }

        // Render the Live Update ourselves; skip OneSignal's default display.
        // Use preventDefault(true) so the SDK discards immediately. The no-arg preventDefault()
        // makes the SDK wait up to 30s for a display() call this pattern never makes, holding the
        // worker that whole time, which is costly when you send an update every few seconds.
        event.preventDefault(true)
        handleLiveUpdate(payload, notificationManager, context)
    }

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

        when (val eventType = payload.optString("event")) {
            "start" -> {
                // A new activity clears any dismissal recorded for the previous one.
                LiveUpdateState.clearDismissed(context, key)
                postForKey(key, attributes, updates, notificationId, context, notificationManager)
            }
            "update" -> {
                // Never repost an update the user already swiped away or unpinned.
                if (LiveUpdateState.isDismissed(context, key)) {
                    logger.info("Skipping update for dismissed Live Update: $key")
                    return
                }
                postForKey(key, attributes, updates, notificationId, context, notificationManager)
            }
            "end" -> {
                notificationManager.cancel(notificationId)
                LiveUpdateState.clearDismissed(context, key)
            }
            else -> logger.warning("Unknown Live Update event: $eventType")
        }
    }

    private fun postForKey(
        key: String,
        attributes: JSONObject,
        updates: JSONObject,
        notificationId: Int,
        context: Context,
        notificationManager: NotificationManager,
    ) {
        when (key) {
            "progress" -> postProgressNotification(
                key, attributes, updates, notificationId, context, notificationManager,
            )
            // Route additional keys to their builders here.
        }
    }

    private fun postProgressNotification(
        key: String,
        attributes: JSONObject,
        updates: JSONObject,
        notificationId: Int,
        context: Context,
        notificationManager: NotificationManager,
    ) {
        // This class keeps no state between pushes, so every value the builder reads has to arrive
        // in the payload. Send event_attributes on updates too, not only on start.
        val title = attributes.optString("title").takeIf { it.isNotEmpty() }
            ?: "Download in progress"
        val currentProgress = updates.optInt("current_progress", 0)

        // Fires when the user swipes the notification away or taps Unpin. Both must stop
        // further updates, or the next push resurrects a notification the user rejected.
        val dismissIntent = LiveUpdateState.dismissIntent(context, key, notificationId)

        val notification = if (Build.VERSION.SDK_INT >= 36) {
            buildLiveUpdate(context, title, currentProgress, dismissIntent)
        } else {
            buildOngoingProgress(context, title, currentProgress, dismissIntent)
        }

        notificationManager.notify(notificationId, notification)
    }

    // API 36 and above: promoted ongoing notification, shown as a Live Update on API 36.1 and above.
    @RequiresApi(36)
    private fun buildLiveUpdate(
        context: Context,
        title: String,
        currentProgress: Int,
        dismissIntent: PendingIntent,
    ): Notification {
        val builder = Notification.Builder(context, PROGRESS_CHANNEL_ID)
            .setContentTitle(title)
            .setContentText("$currentProgress% complete")
            .setSmallIcon(android.R.drawable.ic_media_play)
            .setLargeIcon(Icon.createWithResource(context, android.R.drawable.ic_dialog_info))
            .setOngoing(true)
            .setOnlyAlertOnce(true)
            .setAutoCancel(false)
            .setDeleteIntent(dismissIntent)
            // Give the user an explicit way out. Android asks for an unpin action on activities
            // the user opted into rather than started, such as following a match.
            .addAction(Notification.Action.Builder(null, "Unpin", dismissIntent).build())
            // Safety net: if the `end` push never arrives, the notification clears itself
            // instead of staying pinned forever.
            .setTimeoutAfter(ACTIVITY_TIMEOUT_MS)
            // Status-bar chip text. Android suggests a maximum of 7 characters ("12 min", "LIVE"),
            // and there is no guarantee how much of it is shown. For a live-feeling timer, add
            // setWhen(...) + setUsesChronometer(true) + setChronometerCountDown(true).
            .setShortCriticalText("$currentProgress%")
            .setStyle(
                Notification.ProgressStyle()
                    .setProgress(currentProgress)
                    .setProgressSegments(listOf(Notification.ProgressStyle.Segment(100))),
            )

        // Request promotion into a Live Update (status-bar chip and lock screen placement).
        // Notification.Builder#setRequestPromotedOngoing and Notification.EXTRA_REQUEST_PROMOTED_ONGOING
        // were added in API 36.1, so at compileSdk 36 the extra's string key is the only framework
        // path. On NotificationCompat 1.17.0+ you would call setRequestPromotedOngoing instead.
        builder.addExtras(Bundle().apply { putBoolean("android.requestPromotedOngoing", true) })

        return builder.build()
    }

    // Below API 36: ordinary ongoing progress notification. It still updates in place, but the
    // system never promotes it, so there is no status-bar chip.
    private fun buildOngoingProgress(
        context: Context,
        title: String,
        currentProgress: Int,
        dismissIntent: PendingIntent,
    ): Notification =
        NotificationCompat.Builder(context, PROGRESS_CHANNEL_ID)
            .setContentTitle(title)
            .setContentText("$currentProgress% complete")
            .setSmallIcon(android.R.drawable.ic_media_play)
            .setOngoing(true)
            .setOnlyAlertOnce(true)
            .setAutoCancel(false)
            .setDeleteIntent(dismissIntent)
            .addAction(0, "Unpin", dismissIntent)
            .setTimeoutAfter(ACTIVITY_TIMEOUT_MS)
            .setProgress(100, currentProgress, false)
            .build()

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

        val progressChannel = NotificationChannel(
            PROGRESS_CHANNEL_ID,
            "Progress updates",
            NotificationManager.IMPORTANCE_LOW,
        ).apply {
            description = "Progress updates that replace themselves in place."
            setShowBadge(false)
            enableVibration(false)
            setSound(null, null)
        }
        notificationManager.createNotificationChannel(progressChannel)
    }
}
```

<Note>
  Channels for Live Updates should be low importance with badges, vibration, and sound disabled so frequent updates don't alert the user. See [Android notification categories](./android-notification-categories).
</Note>

The extension references two small helpers. Add both files next to it, in the same package.

`LiveUpdateState` records dismissals in `SharedPreferences` because the extension keeps no memory between pushes. `LiveUpdateDismissReceiver` catches the swipe or the Unpin tap and writes that record.

<CodeGroup>
  ```kotlin LiveUpdateState.kt theme={null}
  package com.onesignal.sample.android

  import android.app.PendingIntent
  import android.content.Context
  import android.content.Intent
  import android.os.Build

  object LiveUpdateState {

      private const val PREFS_NAME = "live_update_state"

      private fun prefs(context: Context) =
          context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)

      fun markDismissed(context: Context, key: String) {
          prefs(context).edit().putBoolean(key, true).apply()
      }

      fun clearDismissed(context: Context, key: String) {
          prefs(context).edit().remove(key).apply()
      }

      fun isDismissed(context: Context, key: String): Boolean =
          prefs(context).getBoolean(key, false)

      // One PendingIntent serves both the swipe-away (deleteIntent) and the Unpin action.
      fun dismissIntent(context: Context, key: String, notificationId: Int): PendingIntent {
          val intent = Intent(context, LiveUpdateDismissReceiver::class.java)
              .setAction(LiveUpdateDismissReceiver.ACTION_DISMISSED)
              .putExtra(LiveUpdateDismissReceiver.EXTRA_KEY, key)
              .putExtra(LiveUpdateDismissReceiver.EXTRA_NOTIFICATION_ID, notificationId)

          // FLAG_IMMUTABLE is required on Android 12+ and available from API 23.
          var flags = PendingIntent.FLAG_UPDATE_CURRENT
          if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
              flags = flags or PendingIntent.FLAG_IMMUTABLE
          }

          // Use the notification ID as the request code so each Live Update gets its own intent.
          return PendingIntent.getBroadcast(context, notificationId, intent, flags)
      }
  }
  ```

  ```kotlin LiveUpdateDismissReceiver.kt theme={null}
  package com.onesignal.sample.android

  import android.app.NotificationManager
  import android.content.BroadcastReceiver
  import android.content.Context
  import android.content.Intent
  import androidx.annotation.Keep

  @Keep
  class LiveUpdateDismissReceiver : BroadcastReceiver() {

      companion object {
          const val ACTION_DISMISSED = "com.onesignal.sample.android.LIVE_UPDATE_DISMISSED"
          const val EXTRA_KEY = "live_update_key"
          const val EXTRA_NOTIFICATION_ID = "live_update_notification_id"
      }

      override fun onReceive(context: Context, intent: Intent) {
          val key = intent.getStringExtra(EXTRA_KEY) ?: return
          LiveUpdateState.markDismissed(context, key)

          // Needed for the Unpin action. A swipe has already removed the notification,
          // and cancel() on an absent notification is a no-op.
          val notificationId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1)
          if (notificationId != -1) {
              val notificationManager =
                  context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
              notificationManager?.cancel(notificationId)
          }
      }
  }
  ```
</CodeGroup>

<Warning>
  Without this dismissal record, your next `update` push reposts a notification the user just swiped away, and it returns every few seconds until the activity ends. Android names that behavior as a reason users revoke promotion permission for an app.
</Warning>

<Accordion title="Why the sample uses two different notification builders">
  The promoted path uses the framework `Notification.Builder`, and the pre-API-36 path uses `NotificationCompat.Builder`. This split is deliberate.

  `NotificationCompat.ProgressStyle` and `NotificationCompat.Builder.setRequestPromotedOngoing` were both added in `androidx.core` 1.17.0, which the OneSignal SDK does not pull in. Building the promoted notification with the framework class avoids forcing a dependency bump on every integrator.

  The tradeoff is the promotion flag. `Notification.Builder.setRequestPromotedOngoing` and `Notification.EXTRA_REQUEST_PROMOTED_ONGOING` were added in API 36.1, so neither is available at `compileSdk 36`. Setting the `android.requestPromotedOngoing` extra by its string key produces the same result. If you already depend on `androidx.core` 1.17.0 or newer, use `NotificationCompat.Builder` with `setRequestPromotedOngoing(true)` for both paths and drop the string literal.
</Accordion>

### 2. Register the extension in the Android Manifest

Open `app/src/main/AndroidManifest.xml` and add three entries. Do not create a new file, and do not add a `<service>` tag. The `<meta-data>` entry alone tells OneSignal which class from Step 1 to call when a push arrives.

* Add `<uses-permission>` at the top level of `<manifest>`, alongside your other permissions and outside `<application>`. `POST_PROMOTED_NOTIFICATIONS` is a non-runtime permission, so declaring it is all you need. There is no prompt to request.
* Add `<meta-data>` inside the existing `<application>` block. Leave `android:name` exactly as shown, and set `android:value` to the fully qualified name of your class (the `package` declaration in `NotificationServiceExtension.kt` plus the class name).
* Add `<receiver>` inside `<application>` so the dismissal handler from Step 1 can run. Keep `android:exported="false"`, since only the system needs to deliver to it.

<CodeGroup>
  ```xml AndroidManifest.xml theme={null}
  <!-- Top level of <manifest>, outside <application>. Required for promoted ongoing notifications. -->
  <uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS" />

  <!-- Inside <application>. -->
  <meta-data android:name="com.onesignal.NotificationServiceExtension"
             android:value="com.onesignal.sample.android.NotificationServiceExtension" />

  <!-- Inside <application>. Receives swipe-away and Unpin events. -->
  <receiver android:name="com.onesignal.sample.android.LiveUpdateDismissReceiver"
            android:exported="false" />
  ```

  ```xml Example AndroidManifest.xml theme={null}
  <?xml version="1.0" encoding="utf-8"?>
  <manifest xmlns:android="http://schemas.android.com/apk/res/android"
      xmlns:tools="http://schemas.android.com/tools">
      <uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS" />

      <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" />
          <receiver
              android:name="com.onesignal.sample.android.LiveUpdateDismissReceiver"
              android:exported="false" />
          <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>
  ```
</CodeGroup>

Treat the second tab as a placement reference, not a replacement for your existing Manifest.

<Info>
  Steps 1 and 2 are the only *required* Android file changes. The remaining steps cover payload format and how to send Live Updates from your backend.
</Info>

### 3. Define your Live Update payload

Each Live Update carries a `live_notification` object inside the push's `data` field. The key name and the schema are conventions you control, and this guide keeps `live_notification` so existing implementations keep working. The shape used here:

| Property | Required | Description |
| - | - | - |
| `key` | Yes | Identifies which Live Update UI to render and which Android notification ID to reuse. |
| `event` | Yes | Lifecycle event: `start`, `update`, or `end`. |
| `event_attributes` | No | Data that stays the same for the life of the notification (team names, order ID, a display title). |
| `event_updates` | No | Data that changes on every event. The shape is whatever your builder consumes (scores, percentages, ETAs). |

**Example payload (start of a download progress Live Update):**

```json theme={null}
{
  "key": "progress",
  "event": "start",
  "event_attributes": {
    "title": "Downloading Season 1"
  },
  "event_updates": {
    "current_progress": 0
  }
}
```

Two constraints shape what you put in here:

* **`key` must map to an Android notification ID your extension already knows.** The sample in Step 1 registers `progress` in `keyMap`. A payload with an unregistered key logs a warning and renders nothing, so add an entry to `keyMap` and a builder for every key you send.
* **Resend `event_attributes` on every event, not only on `start`.** The service extension only runs when a push arrives and keeps no state between pushes, so anything it read on `start` is gone by the time the first `update` arrives.

<Warning>
  FCM caps the total push payload at 4 KB. Keep `event_attributes` and `event_updates` to the fields your builder actually reads, and send IDs rather than full objects your app can already resolve locally.
</Warning>

### 4. Handle the lifecycle events

Your service extension dispatches on the `event` field. The example code above does this in `handleLiveUpdate`.

| Event | Description | Required fields |
| - | - | - |
| `start` | Creates the Live Update. | `event_attributes`, `event_updates` |
| `update` | Updates the existing Live Update in place. | `event_updates`, plus `event_attributes` if your builder reads it |
| `end` | Removes the Live Update by calling `NotificationManager.cancel(id)`. | None |

The sample also treats dismissal as part of the lifecycle. `start` clears any dismissal recorded for that `key`, `update` is skipped while a dismissal is on record, and `end` cancels the notification and clears the record so the next activity starts clean.

<Warning>
  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.
</Warning>

***

## Confirm a Live Update will promote

The manifest permission is only half of it. Two independent checks tell you *why* a notification isn't promoting, and they fail for different reasons:

* **`Notification.hasPromotableCharacteristics()`** returns false when the notification itself doesn't qualify. It must be ongoing, have a `contentTitle`, and use Standard, BigText, Call, Progress, or Metric style. It must not set `colorized` to true, set a `customContentView`, be a group summary, or post to an `IMPORTANCE_MIN` channel. This check ignores user settings.
* **`NotificationManager.canPostPromotedNotifications()`** returns false when the user has turned promotion off for your app.

To check the notification itself, call `hasPromotableCharacteristics()` on the built notification inside your extension and log the result. If it returns false, fix the builder against the list above.

To check the user setting, deep-link the user to the promotion screen. Call this from your app UI, not from the service extension, since the extension has no Activity to start from:

```kotlin theme={null}
// Requires: android.app.NotificationManager, android.content.Context, android.content.Intent,
// android.os.Build, android.provider.Settings
fun promptForLiveUpdatePermission(context: Context) {
    if (Build.VERSION.SDK_INT < 36) return

    val notificationManager =
        context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
    if (notificationManager.canPostPromotedNotifications()) return

    val intent = Intent(Settings.ACTION_APP_NOTIFICATION_PROMOTION_SETTINGS)
        .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
        .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)

    // Android does not guarantee a matching Activity exists, so resolve before starting it.
    if (intent.resolveActivity(context.packageManager) != null) {
        context.startActivity(intent)
    }
}
```

Both `canPostPromotedNotifications()` and `ACTION_APP_NOTIFICATION_PROMOTION_SETTINGS` were added in API 36.

<Warning>
  Some Android documentation refers to this intent action as `Settings.ACTION_MANAGE_APP_PROMOTED_NOTIFICATIONS`. That constant does not exist. Use `Settings.ACTION_APP_NOTIFICATION_PROMOTION_SETTINGS`.
</Warning>

Without these checks, "the user disabled Live Updates" and "the notification is malformed" look identical from your side. Both produce a notification that updates in place but never promotes, with nothing in the logs to tell them apart.

<Note>
  Passing both checks still doesn't guarantee promotion. Device manufacturers can add their own criteria for Live Updates, so a notification that promotes on a Pixel may not promote on every device.
</Note>

***

## Verify you're ready to test

Before sending curl requests, confirm the following:

* The device shows as subscribed in the OneSignal dashboard.
* Push permission is granted on the device.
* You have a [Subscription ID](./subscriptions) or [External ID](./users#external-id) for the target user.
* You have your app's REST API key from **Settings → [Keys & IDs](./keys-and-ids)**.

<Warning>
  The examples below target `include_aliases.external_id`, which only works if your app has called `OneSignal.login("EXTERNAL_ID")` for that user. If `login` was never called, the send fails and no notification arrives. See [External ID](./users#external-id). If you are not sure, swap the targeting block for a Subscription ID from the dashboard, which always resolves:

  ```json theme={null}
  "include_subscription_ids": ["SUBSCRIPTION_ID"],
  ```

  `include_subscription_ids` replaces both `include_aliases` and `target_channel`, so remove those two fields when you use it.
</Warning>

***

## Send a Live Update

Send `start`, `update`, and `end` events with the [Create message API](/reference/create-message). Every request must:

* Set the **same `collapse_id`** on every event so an offline device receives only the newest update. The ID must be unique to that specific Live Update instance.
* Target the same set of users (typically with `include_subscription_ids` or `include_aliases.external_id`).
* Use `isAndroid: true` to restrict delivery to Android push subscriptions.

Set a short `ttl` on `update` pushes, in seconds, roughly matching how long that value stays meaningful. Without it an update queued for an offline device can arrive long after the activity finished and repost a notification for something that's already over. Leave `end` at the default so it still lands after a gap in connectivity.

### Start

Send `event: "start"` to create the Live Update. Initialize both static (`event_attributes`) and dynamic (`event_updates`) data.

```bash Start theme={null}
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",
  "include_aliases": {
      "external_id": [
        "EXTERNAL_ID"
      ]
  },
  "target_channel": "push",
  "isAndroid": true,
  "collapse_id": "UNIQUE_LIVE_NOTIFICATION_ID",
  "data": {
    "live_notification": {
      "key": "progress",
      "event": "start",
      "event_attributes": { "title": "Downloading Season 1" },
      "event_updates": { "current_progress": 0 }
    }
  },
  "headings": { "en": "Start" },
  "contents": { "en": "Starting Live Update" }
}'
```

### Update

Send `event: "update"` with new `event_updates` data. You can update as many times as you like after `start`. Repeat `event_attributes` in each update if your builder reads it, since the extension retains nothing from the `start` push.

```bash Update theme={null}
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",
  "include_aliases": {
      "external_id": [
        "EXTERNAL_ID"
      ]
  },
  "target_channel": "push",
  "isAndroid": true,
  "collapse_id": "UNIQUE_LIVE_NOTIFICATION_ID",
  "ttl": 120,
  "data": {
    "live_notification": {
      "key": "progress",
      "event": "update",
      "event_attributes": { "title": "Downloading Season 1" },
      "event_updates": { "current_progress": 80 }
    }
  },
  "headings": { "en": "Update" },
  "contents": { "en": "Updating Live Update" }
}'
```

### End

Send `event: "end"` to dismiss the Live Update. The service extension calls `NotificationManager.cancel(id)` to remove it from the user's device.

```bash End theme={null}
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",
  "include_aliases": {
      "external_id": [
        "EXTERNAL_ID"
      ]
  },
  "target_channel": "push",
  "isAndroid": true,
  "collapse_id": "UNIQUE_LIVE_NOTIFICATION_ID",
  "data": {
    "live_notification": {
      "key": "progress",
      "event": "end"
    }
  },
  "headings": { "en": "Ending" },
  "contents": { "en": "Ending Live Update" }
}'
```

***

## FAQ

### Are Android Live Updates 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 Update state, manage update lifecycles, or expose Live Updates in the dashboard. The schema and event names in this guide are conventions you can change.

### Why isn't my Live Update promoting?

Check the two independent gates. `Notification.hasPromotableCharacteristics()` is false when the notification itself doesn't qualify: missing content title, ineligible style, colorized, or an `IMPORTANCE_MIN` channel. `NotificationManager.canPostPromotedNotifications()` is false when the user disabled Live Updates for your app, so deep-link them to `Settings.ACTION_APP_NOTIFICATION_PROMOTION_SETTINGS` to re-enable it. Also confirm the device is on Android 16 QPR1 (API 36.1) or higher, because Android 16.0 renders the notification and updates it in place but never promotes it. See [Confirm a Live Update will promote](#confirm-a-live-update-will-promote).

### Why doesn't my Live Update appear in the Now Bar on my Galaxy device?

Samsung integrates Android's Live Updates into the Now Bar, but some One UI builds have gated third-party Live Updates behind a developer setting named **Live notifications for all apps**, leaving the Now Bar to Samsung's own apps by default. If your notification promotes correctly elsewhere on the device (status bar chip, lock screen, top of the drawer) but never reaches the Now Bar, check that setting on the test device before changing your code. Device manufacturers can also add their own eligibility criteria, so confirm behavior on the specific devices your audience uses rather than on one reference phone.

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

They solve different problems, and only the notification ID controls what the user sees. The stable Android notification ID is what `NotificationManager.notify(id, …)` uses to replace the visible notification's contents instead of posting a new one. `collapse_id` maps to the FCM `collapse_key`, which discards superseded messages queued for a device that is currently offline, so a device that reconnects after ten updates receives the last one rather than all ten. Set both: the ID for correct rendering, the `collapse_id` to avoid a flood of stale updates on reconnect.

### 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 alerts the user every time and is the most common reason support tickets get opened for this pattern.

### Do Live Updates appear correctly in message reports?

Partly, and the gaps are worth knowing before you read the numbers. Each `start`, `update`, and `end` is a separate push, so a single Live Update produces one message report per event rather than one report for the whole lifecycle. Delivery still tracks normally, including [Confirmed Delivery](./confirmed-delivery), because the SDK reports the push as received before your extension decides how to render it. Clicks do not: the user taps a notification your app posted through `NotificationManager`, not OneSignal's, so OneSignal cannot attribute the tap and click metrics stay at zero.

### What happens when the user swipes the Live Update away?

Your next `update` push reposts it, unless you record the dismissal. The extension runs fresh on every push and has no memory of what the user did, so Step 1 wires a `setDeleteIntent` and an Unpin action to a receiver that writes the dismissal to `SharedPreferences`, and skips `update` events while that record exists. Android tells apps not to repost dismissed Live Updates, and reposting is one of the reasons users revoke promotion permission. A later `start` for the same `key` clears the record, so the next activity displays normally.

### 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](./live-activities). It uses ActivityKit and is built into OneSignal as a first-class feature with its own [Start](/reference/start-live-activity) and [Update](/reference/update-live-activity-api) APIs. Use Live Activities on iOS rather than this pattern.

***

## Related pages

<Columns cols={2}>
  <Card title="Service extensions" icon="puzzle-piece" href="./service-extensions">
    Intercept and customize push notifications before display on Android and iOS.
  </Card>

  <Card title="Create message API" icon="paper-plane" href="/reference/create-message">
    The API used to send the start, update, and end pushes for a Live Update.
  </Card>

  <Card title="Live Activities (iOS)" icon="apple" href="./live-activities">
    The managed iOS equivalent built on ActivityKit.
  </Card>

  <Card title="Android notification categories" icon="bell" href="./android-notification-categories">
    Configure channels so frequent Live Update updates don't alert the user.
  </Card>
</Columns>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.