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
NotificationCompatupdated 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
endfrom your server to free the lock-screen view cleanly. - iOS topic suffix —
<bundleId>.push-type.liveactivityis mandatory in the APNs request. Backend sets it; you don't. - iOS timestamp —
aps.timestampmust be monotonic. Backend usesMath.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(orInboxStyle) insidebuildNotificationwhenBuild.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).