Skip to content
Sendora Cloud
Create account
Module deep-dive

Live Activities (iOS) + Live Updates (Android)

Persistent rich notifications that update in-place: live scores, delivery trackers, ride status, countdown timers. Lock-screen + Dynamic Island on iOS 16.1+ via ActivityKit; ongoing notification via NotificationCompat.ProgressStyle on Android API 26+. Cross- platform server-side dispatch via PATCH /push/live-activities/:id/update.

Tier — Growth+ on every Live Activity route

All Live Activity API routes (POST /push/live-activities/start-token, GET /push/live-activities, PATCH /push/live-activities/:id/update, DELETE /push/live-activities/:id) gate on Growth+. Lower tiers receive 402 PAYMENT_REQUIRED tier_required. Activities can't be created on Free / Starter, so the "orphans accumulate forever" case doesn't arise.

SDK vs host-app boundary — and why iOS / Android differ

Sendora SDK does not wrap Activity<Attrs>.request(...) on iOS. The host app calls ActivityKit directly to start the activity (it owns the Attrs + ContentState types defined in your Widget Extension). After start, hand the resulting Activity instance to SendoraCloud.liveActivities?.track(activity:...) — the SDK then watches activity.pushTokenUpdates and registers each rotation with Sendora via POST /push/live-activities/start-token. Server updates dispatch via APNs push-type=liveactivity against that token.

Android is more wrapped: SDK has start(...) + handleFcmMessage(...) + ensureChannel(...) because there's no native ActivityKit equivalent — what Android calls a "live update" is just a regular NotificationCompat ongoing notification mutated by FCM data-only push. SDK abstracts the FCM-routing + notification-update glue. iOS doesn't need that wrapper because ActivityKit already provides the primitive.

Why the asymmetry: ActivityKit on iOS owns the UI layer (lock-screen + Dynamic Island regions are rendered by the system from your Widget Extension code, not by the SDK). Wrapping Activity.request(...) in the SDK would mean re-typing the dev's ActivityAttributes generic parameter through Sendora — forcing them to either duplicate types or surrender Swift type-safety. Direct ActivityKit + post-creation registration via SendoraCloud.liveActivities?.track(activity:...) keeps your types yours. Android has no such generic-type burden, so the SDK can wrap end-to-end.

Android signature detail

Concrete Kotlin signatures (stable since sdk-android 3.4.0):

fun ensureChannel(context: Context)

fun start(
    fcmToken: String,
    activityType: String,
    attributes: Map<String, Any?>,        // your immutable per-activity data
    contentState: Map<String, Any?>,      // mutates per server update
    externalId: String? = null,           // your domain id (e.g. "order-1234")
    userId: String? = null,
    onResult: (activityId: String) -> Unit,
)

fun handleFcmMessage(
    context: Context,
    data: Map<String, String>,            // RemoteMessage.data
    buildNotification: (contentState: JSONObject) -> Notification,
): Boolean

fun dismissLocally(context: Context, activityId: String)

data is the raw FCM payload map. SDK reads three keys it stamps server-side: sendoraLiveActivityId, sendoraLiveActivityEvent (one of update, end), sendoraContentState (JSON-stringified). Returns true if the message was a Sendora live-activity push (your onMessageReceived can short-circuit on true). buildNotification renders the visible ongoing notification from the parsed contentState.

Model

One row in push_live_activities per running activity. Host app starts the activity locally, captures the per-activity push token, registers with Sendora. Server-side updates dispatch via APNs (iOS, push-type=liveactivity) or FCM data- only push (Android, priority HIGH).

  • iOS: ActivityKit + Widget Extension. Token rotates per-activity; SDK tracks via activity.pushTokenUpdates.
  • Android: regular ongoing NotificationCompat updated via FCM data-only payload. No native equivalent of APNs Live Activity protocol.
  • Server-side: same routes for both — backend routes by pushLiveActivities.platform.

iOS — host-app setup

1. Enable in Info.plist

<key>NSSupportsLiveActivities</key>
<true/>

2. Create a Widget Extension target

Xcode → File → New → Target → Widget Extension. Tick "Include Live Activity." The generated bundle includes ActivityConfiguration with the lock-screen view + Dynamic Island regions.

3. Define ActivityAttributes

import ActivityKit

struct OrderAttributes: ActivityAttributes {
    public struct ContentState: Codable, Hashable {
        var status: String        // "preparing" | "out_for_delivery" | "delivered"
        var minutesAway: Int
        var driverName: String?
    }
    var orderId: String   // immutable; ContentState mutates
}

4. Render in Widget Extension

import WidgetKit
import SwiftUI

struct OrderLiveActivityWidget: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: OrderAttributes.self) { context in
            // Lock-screen view
            VStack(alignment: .leading) {
                Text("Order #\(context.attributes.orderId)")
                    .font(.headline)
                Text("\(context.state.status) — \(context.state.minutesAway) min away")
            }
            .padding()
        } dynamicIsland: { context in
            DynamicIsland {
                DynamicIslandExpandedRegion(.leading) {
                    Image(systemName: "shippingbox")
                }
                DynamicIslandExpandedRegion(.trailing) {
                    Text("\(context.state.minutesAway) min")
                }
                DynamicIslandExpandedRegion(.center) {
                    Text(context.state.status)
                }
                DynamicIslandExpandedRegion(.bottom) {
                    EmptyView()
                }
            } compactLeading: {
                Image(systemName: "shippingbox")
            } compactTrailing: {
                Text("\(context.state.minutesAway)m")
            } minimal: {
                Image(systemName: "shippingbox")
            }
        }
    }
}

5. Start + register

import ActivityKit
import SendoraCloud

let activity = try Activity<OrderAttributes>.request(
    attributes: OrderAttributes(orderId: "1234"),
    contentState: OrderAttributes.ContentState(
        status: "preparing",
        minutesAway: 30,
        driverName: nil
    ),
    pushType: .token   // REQUIRED for server-side push updates
)

if #available(iOS 16.1, *) {
    SendoraCloud.liveActivities?.track(
        activity: activity,
        activityType: "OrderAttributes",
        externalId: "order-1234",
        userId: "user-42"
    )
}

SDK watches activity.pushTokenUpdates + re-registers with Sendora on every rotation. pushType: .token is mandatory — without it the activity is local-only and server pushes silently fail.

Android — host-app setup

1. Notification channel

SendoraCloud.liveActivities?.ensureChannel(applicationContext)
// Creates channel id "sendora_live_updates" with IMPORTANCE_LOW (silent updates).

2. Start the activity

val fcmToken = FirebaseMessaging.getInstance().token.await()

SendoraCloud.liveActivities?.start(
    fcmToken = fcmToken,
    activityType = "OrderTracker",
    attributes = mapOf("orderId" to "1234"),
    contentState = mapOf("status" to "preparing", "minutesAway" to 30),
    externalId = "order-1234",
    userId = "user-42",
) { activityId ->
    // Persist activityId for later end()
}

3. Render incoming updates in FirebaseMessagingService

class MyFcm : FirebaseMessagingService() {
    override fun onMessageReceived(message: RemoteMessage) {
        // Sendora live-update payload carries:
        //   sendoraLiveActivityId, sendoraLiveActivityEvent, sendoraContentState
        SendoraCloud.liveActivities?.handleFcmMessage(
            applicationContext,
            message.data,
            buildNotification = { contentState ->
                NotificationCompat.Builder(this, "sendora_live_updates")
                    .setSmallIcon(R.drawable.ic_status)
                    .setContentTitle("Order #" + contentState.optString("orderId"))
                    .setContentText("Status: " + contentState.optString("status"))
                    .setOngoing(true)
                    .setOnlyAlertOnce(true)
                    .build()
            },
        )
    }
}

API 34+ — ProgressStyle

Android 14+ introduced NotificationCompat.ProgressStyle for delivery / install / countdown UIs. Use it in buildNotification when Build.VERSION.SDK_INT >= 34; fall back to BigTextStyle on older Android.

Server-side update + end

Same routes for both platforms — backend routes via pushLiveActivities.platform column.

# Update — push new ContentState. Optional alert wakes screen.
curl -X PATCH https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/push/live-activities/<ACTIVITY_UUID>/update \
  -H "x-api-key: sk_prod_..." \
  -d '{
    "contentState": { "status": "out_for_delivery", "minutesAway": 5 },
    "alert": { "title": "Out for delivery", "body": "5 min away" }
  }'

# End — locks screen view + frees server-side row.
curl -X DELETE https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/push/live-activities/<ACTIVITY_UUID> \
  -H "x-api-key: sk_prod_..."

From a workflow, use the (TBD) live_activity_update step type. For now, fire from your server in response to your own domain events.

Budgets + gotchas

  • iOS update budget — Apple gives each activity ~5 high-priority updates per hour. Beyond that, APNs may silently throttle. Backend tracks per-activity update count + warns at updatesThisHour ≥ 30.
  • iOS lifetime — activities auto-end at 8h idle / 12h max. Plan for it; emit a final end from your server to free the lock-screen view cleanly.
  • iOS topic suffix <bundleId>.push-type.liveactivity is mandatory in the APNs request. Backend sets it; you don't.
  • iOS timestampaps.timestamp must be monotonic. Backend uses Math.floor(Date.now()/1000) per send.
  • Android battery optimization — known aggressive OEMs that delay FCM data-only delivery until app foreground: Xiaomi (MIUI), Huawei (EMUI), Honor, OnePlus (OxygenOS pre-13), Oppo (ColorOS), Realme, Vivo (Funtouch / OriginOS), Tecno (HiOS), Infinix (XOS), Samsung One UI < 4 with aggressive Sleeping Apps. Stock Pixel + recent Samsung (One UI 5+) deliver promptly. No SDK fix — document the limitation in your in-app walkthrough; instruct affected users to whitelist your app under Battery → Background activity.
  • Pre-Android-14 ProgressStyle fallback — host app builds a regular NotificationCompat.BigTextStyle (or InboxStyle) inside buildNotification when Build.VERSION.SDK_INT < 34. Live updates still flow; UI is just a static ongoing notification rather than the segmented progress bar.
  • Android POST_NOTIFICATIONS — runtime permission required on API 33+. Host app handles before calling start().
  • iOS 17+ APNs-as-trigger — APNs can also START activities (not just update). Sendora doesn't expose this yet — host app must start v1.

Analytics events

Each lifecycle transition emits an event you can branch on in workflows or subscribe to via webhook:

  • push.live_activity_started — token registered.
  • push.live_activity_updated — server-side update dispatched.
  • push.live_activity_ended — server-side end called.
  • push.live_activity_invalidated — APNs/FCM rejected the per-activity token (activity-side dead).

More