Skip to content

Remote Ongoing Notifications (Android)

Voltra can apply remote updates to an ongoing notification when your app receives a push notification and handles it in a background task. This builds on the local lifecycle API — see Managing Ongoing Notifications first for creating the notification channel, requesting permission, and the start/update/stop APIs.

Voltra provides a server-side API for converting JSX into the semantic payload used by Android ongoing notifications.

Use these APIs only in server-side or backend code. Do not import them from your React Native app runtime.

Use @use-voltra/android-server.

The main renderer APIs are:

  • renderAndroidOngoingNotificationPayloadToJson() returns an object
  • renderAndroidOngoingNotificationPayload() returns a JSON string

This API only renders the payload. Your server still needs to send that payload through your push provider, and your app still needs a background task that calls upsertAndroidOngoingNotification() or stopAndroidOngoingNotification() when the push arrives.

You should use expo-notifications together with expo-task-manager to process real push notifications and update ongoing notifications in the background.

Register a background task early in app startup, so incoming pushes can update or stop an ongoing notification even when the app is backgrounded:

import * as Notifications from 'expo-notifications'
import * as TaskManager from 'expo-task-manager'
const TASK_NAME = 'voltra-ongoing-notification-task'
TaskManager.defineTask(TASK_NAME, async ({ data, error }) => {
if (error) {
return
}
// Read your push payload and call Voltra APIs here.
})
await Notifications.registerTaskAsync(TASK_NAME)

The end-to-end flow is:

  1. Your server renders Voltra JSX into an Android ongoing-notification payload.
  2. Your server sends a high-priority push notification.
  3. The push data contains a voltraOngoingNotification object.
  4. Your background task parses that object.
  5. The task calls upsertAndroidOngoingNotification() or stopAndroidOngoingNotification().

Use renderAndroidOngoingNotificationPayloadToJson() when preparing a payload on your server or in app tooling:

import {
AndroidOngoingNotification,
renderAndroidOngoingNotificationPayloadToJson,
} from '@use-voltra/android-server'
const payload = renderAndroidOngoingNotificationPayloadToJson(
<AndroidOngoingNotification.Progress
title="Driver is approaching"
text="2 stops away"
value={80}
max={100}
>
<AndroidOngoingNotification.Action
title="Open order"
deepLinkUrl="myapp://orders/123"
/>
</AndroidOngoingNotification.Progress>
)

Then send that payload inside a push message.

A picture works the same way:

const payload = renderAndroidOngoingNotificationPayloadToJson(
<AndroidOngoingNotification.BigPicture
title="Parcel delivered"
text="Left at the front door"
picture={{ assetName: 'delivery_photo_123' }}
summaryText="Order 123"
/>
)
{
"v": 1,
"kind": "bigPicture",
"title": "Parcel delivered",
"text": "Left at the front door",
"picture": {
"assetName": "delivery_photo_123"
},
"summaryText": "Order 123"
}

An inbox payload carries its lines in the same shape:

const payload = renderAndroidOngoingNotificationPayloadToJson(
<AndroidOngoingNotification.Inbox
title="3 stops remaining"
lines={['12 Oak Street', '4 Elm Road', 'Depot']}
summaryText="Route 7"
/>
)
{
"v": 1,
"kind": "inbox",
"title": "3 stops remaining",
"text": "12 Oak Street",
"lines": ["12 Oak Street", "4 Elm Road", "Depot"],
"summaryText": "Route 7"
}

If your push provider expects strings for nested payload data, use renderAndroidOngoingNotificationPayload() instead and send the JSON string directly.

2. Send the payload through your push provider

Section titled “2. Send the payload through your push provider”

Your payload’s data.voltraOngoingNotification should contain:

  • notificationId: the stable notification identifier
  • operation: 'upsert' or 'stop'
  • options: start options such as channelId, smallIcon, deepLinkUrl, requestPromotedOngoing, or fallbackBehavior; the presentation options visibility, color, category, timeoutMs, localOnly, group, sortKey, and allowSystemGeneratedContextualActions; and alert
  • payload: the Voltra semantic payload for 'upsert'

Presentation options are the app’s decision, so send them only if your push builder is allowed to make it. An upsert whose options leaves them out changes nothing about how the running notification is presented, and a presentation option sent as null clears the stored value when the push updates an existing notification. alert: true makes an updating push alert the way a first post does; a push that starts a notification always alerts anyway.

The payload itself can carry publicVersion, showWhen, and chronometerCountDown, like any other content field.

Example Expo push request:

{
"to": "ExponentPushToken[project-token]",
"priority": "high",
"data": {
"voltraOngoingNotification": "{\"notificationId\":\"order-123\",\"operation\":\"upsert\",\"options\":{\"channelId\":\"delivery_updates\",\"deepLinkUrl\":\"myapp://orders/123\",\"requestPromotedOngoing\":true,\"visibility\":\"private\",\"color\":\"#1E88E5\",\"category\":\"navigation\",\"timeoutMs\":1800000,\"group\":\"deliveries\"},\"payload\":{\"v\":1,\"kind\":\"progress\",\"title\":\"Driver is approaching\",\"text\":\"2 stops away\",\"value\":80,\"max\":100,\"when\":1758540000000,\"showWhen\":false,\"publicVersion\":{\"title\":\"Delivery in progress\",\"text\":\"Unlock to see the driver\"}}}"
}
}

Voltra accepts either an object or a JSON string for data.voltraOngoingNotification. Stringifying it is often the safest option when sending through push providers.

To stop the notification remotely, send the same notificationId with operation: "stop" and omit payload.

Pictures in a remote payload. A bigPicture or largeIcon entry in a push payload should reference an image the app can already find, through assetName for a bundled or preloaded image. Android push providers cap the data of a message well below the size of a photo — Firebase caps a data message at 4096 bytes — so a base64 picture does not fit. Download the image on the device with preloadImages() from @use-voltra/android-client, see Image Preloading, and send its key.

3. Apply the payload in your background task

Section titled “3. Apply the payload in your background task”
import * as Notifications from 'expo-notifications'
import * as TaskManager from 'expo-task-manager'
import {
stopAndroidOngoingNotification,
upsertAndroidOngoingNotification,
} from '@use-voltra/android-client'
const TASK_NAME = 'voltra-ongoing-notification-task'
const parseMessage = (value: unknown) => {
if (typeof value === 'string') {
try {
return JSON.parse(value)
} catch {
return null
}
}
return value
}
TaskManager.defineTask(TASK_NAME, async ({ data, error }) => {
if (error) {
return
}
const message = parseMessage(data?.voltraOngoingNotification)
if (!message || typeof message !== 'object') {
return
}
const notificationId = typeof message.notificationId === 'string' ? message.notificationId : null
if (!notificationId) {
return
}
if (message.operation === 'stop') {
await stopAndroidOngoingNotification(notificationId)
return
}
if (!message.payload || !message.options?.channelId) {
return
}
await upsertAndroidOngoingNotification(message.payload, {
...message.options,
notificationId,
})
})
await Notifications.registerTaskAsync(TASK_NAME)

When the options request promotion, a successful upsert result carries result.promotion.reasons — the machine-readable list of what would block promotion on this device — so your handler can log it. A payload that breaks a rule (kind unknown, malformed JSON, a countdown without when) rejects with a VOLTRA_NOTIFICATION_... code instead of posting something broken, and a channel that was never created rejects too. Wrap the call in try/catch if you want to react to those.

AndroidOngoingNotification.Metric renders server-side too, so one metric payload fits every device: below Android 17 it posts as a standard notification whose text line joins the readings, and the upsert result then carries styleFallback: 'standard'.

Your background task should ensure that the target notification channel exists before calling upsertAndroidOngoingNotification(). Create the channel on startup, and also ensure it exists again inside the background handler.

The client chooses its parser by the kind field, so your server and the installed app have to agree on the available kinds. A build that does not know bigPicture or inbox fails to parse that payload rather than falling back to a layout it does know, and the update is reported as a failure. Serve progress or bigText to older installs, which means a server that picks a layout per device needs the app version that device reports.