Live Activities
A Live Activity is an iOS surface that lives on the Lock Screen and in the Dynamic Island and updates in place while something is happening — a delivery en route, a match in progress, a build finishing. OpenPush drives them over APNs the same way it drives push, but the model is different in one important way: you are not sending a notification, you are pushing a new state into a widget the device is already showing.
Two route families are involved, and they do different jobs. The device side — registering the tokens ActivityKit hands you — runs on /v1 with your SDK key, and the SDK does it for you. The sending side — start, update, end across an audience — is a OneSignal-shaped route family that takes your REST key.
All examples use https://app.openpush.ai, the OpenPush API base URL.
When to use
- Something with a start, a middle, and an end, on a timescale of minutes to a few hours.
- State the user wants to glance at repeatedly without opening the app.
- A countdown, a progress bar, a score, a status ladder.
Do not reach for a Live Activity for a one-shot announcement, for anything longer than half a day (see the windows), or for anything Android-first — Android has no ActivityKit equivalent, only a loosely analogous ongoing notification.
Requirements
| Requirement | Value |
|---|---|
| iOS deployment target | 16.0 (the OpenPush iOS SDK's minimum) |
| Live Activities | iOS 16.1+ |
| Push to start | iOS 17.2+ |
| SDK products | OpenPush plus OpenPushLiveActivities |
| Credentials | An APNs p8 key configured for the app — see APNs setup |
| App configuration | A Widget Extension with an ActivityAttributes type, and NSSupportsLiveActivities in your app's Info.plist |
Your ActivityAttributes struct name is the activity type. It appears in the URL path of the start route and is sent to APNs as the attributes-type, so it has to match the Swift type exactly.
The Unity SDK exposes the same Live Activity surface on iOS builds; on other platforms the calls log an "iOS only" note and do nothing. See Unity SDK.
Two ways to start
Push to start (no app launch required)
On iOS 17.2 and later, ActivityKit gives you a push-to-start token per activity type as soon as your app registers for one. That token is a standing capability: hand it to OpenPush once and you can start Live Activities on that device from your server thereafter, with the app closed.
Register it during setup:
Code
Or, for a specific attributes type, use setup(_:options:) / setPushToStartToken(activityType:token:completion:). The SDK posts to POST /v1/apps/{app_id}/live-activities/push-to-start with your SDK key. Push-to-start tokens have no expiry window — they stay valid until the device rotates them (a rotation replaces the row) or you remove them.
To stop being able to start activities on a device, call removePushToStartToken(_:), which hits .../push-to-start/remove.
Locally started, remotely updated
If the activity begins in response to something the user did in the app, start it with ActivityKit yourself and register the resulting update token with OpenPush so the server can push new content into it:
Code
This posts to POST /v1/apps/{app_id}/live-activities. The device's ordinary push token must already be registered on the app — if it is not, the call comes back 404 telling you to register the device first. The update token must be a valid hex APNs token.
You may pass a stale_at (unix seconds). It is silently clamped into the eight-hour window described below; a later value is accepted and quietly reduced.
Registration is keyed on (app, subscription, activity id). Re-registering the same activity id from the same device refreshes the update token and clears any end marker, but it does not reset started_at — you cannot extend an activity's window by rotating its token.
Sending: start, update, end
The sending routes are mounted at the server root, not under /v1. Their paths and their body field names are deliberately shaped like OneSignal's, so a migrating backend needs minimal change rather than a rewrite — but this is not a reimplementation of OneSignal's API, and a base-URL-and-key swap alone will not carry an existing integration across. Read the caveat under Start on silently ignored fields before you cut over.
They accept either Authorization: Key <REST key> or X-OP-API-Key: <REST key>. In the Authorization form the key is the literal text after the scheme word — it is never base64-encoded, even when the scheme word is Basic.
Start
Code
Code
201. Field rules:
| Field | Required | Rules |
|---|---|---|
event | yes | Must be exactly "start" on this route |
activity_id | yes | Non-blank; must not contain / |
event_attributes | yes | Non-empty object → ActivityKit attributes |
event_updates | yes | Non-empty object → content-state |
name | yes | Non-blank, ≤128 characters. Names the message record. |
contents | yes | Language map; must contain a non-blank en |
headings | yes | Same |
stale_date | no | Unix seconds. A value ≥ 1e11 is rejected as milliseconds. |
dismissal_date | — | Rejected on a start |
priority | no | Exactly 5 or 10. Default 10. |
ios_relevance_score | no | Float in [0, 1] |
ios_sound / sound | no | First non-blank wins |
idempotency_key | no | Must be a UUID. See below. |
| Targeting | yes | Exactly one of include_aliases, include_subscription_ids, included_segments, filters |
excluded_segments is only valid alongside included_segments. include_aliases is an object of label → id or list of ids; the labels external_id, onesignal_id, subscription_id, and openpush_id are resolved as well-known identifiers, anything else as a custom alias.
The activity type comes from the path and is forced as the attributes-type — there is no body field that can change it.
Before sending, the server checks that at least one device has a push-to-start token for that activity type. If none does, you get a 400 saying so rather than a silent no-op.
Note. Unknown body keys are silently ignored on these routes. OneSignal fields such as
ios_interruption_level,apns_push_type_override,subtitle,is_ios, andcustom_dataare not accepted and will disappear without an error. Check your payload against the tables here rather than against a OneSignal request you already have.
Update and end
Code
Code
201. Note the response key on this route is id, not notification_id.
| Field | Required | Rules |
|---|---|---|
event | yes | "update" or "end" |
event_updates | yes | Non-empty object, on both events |
name | yes | ≤128 characters |
contents, headings | no | Optional — but when present must still contain a non-blank en |
stale_date | no | Unix seconds |
dismissal_date | no | Unix seconds; end only |
priority | no | 5 or 10. Default 10 — but see APNs behaviour. |
ios_relevance_score | no | 0–1 |
sound / ios_sound | no |
Targeting is fixed to the activity id in the path; segments and aliases are not accepted here.
If no open activity carries that id you get 404 "no live activity with that id is open on this app — an activity that has ended can never be updated again". If the id is registered under more than one activity type, that is a 400.
Ending from the device
The SDK's exit(_:completion:) posts to POST /v1/apps/{app_id}/live-activities/{activity_id}/end and stamps that device's row as ended. It never deletes the record, and it only affects the calling device.
Activities are also closed automatically when the subscription that owns them is retired or its push token is superseded.
Click tracking
Live Activity taps do not arrive through the normal notification click path — the widget opens a URL. The iOS SDK gives you a widget URL scheme and a helper that reports the tap and hands back your original destination:
Code
The helper posts to POST /v1/apps/{app_id}/live-activities/{activity_id}/click with the device token and, optionally, the notification id. If nothing matches, the call returns 404 rather than inventing an attribution.
A tap increments the message's click counter every time, while the per-device click timestamp is only ever written once — so totals reflect all taps and the funnel still reflects distinct devices.
Note. The Live Activity delivery path records provider-accepted and error states only. It never writes a confirmed-receipt timestamp, so Live Activity sends do not produce confirmed-delivery figures the way ordinary pushes can.
Idempotency
The start route is the only place in OpenPush that supports idempotency, and it is a body field, not a header.
- Pass
idempotency_keyas a UUID. - A first use records the key against the resulting message id.
- A replay within 30 days returns
201with the originalnotification_idand the headerIdempotent-Replayed: true, without sending anything. - Keys are scoped per app.
There is no idempotency on the update/end route, and none anywhere else in the API. See Security and limits.
APNs behaviour
Worth knowing when you are debugging why something did or did not appear:
- Live Activity pushes are sent with
apns-push-type: liveactivityand a topic of your bundle id plus the suffix.push-type.liveactivity. - Priority defaults differ by event:
startandenddefault to 10,updatedefaults to 5. An explicitpriorityin the body wins. Frequent priority-10 updates are what get an app throttled by iOS, so leave updates at 5 unless the state change is urgent. - The
timestampin the aps payload is always generated by the server. Apple drops updates whose timestamp is not increasing, so this is not something a caller can supply. content-stateis required and must be non-empty. On a start,attributes-type,attributes, andinput-push-tokenare also included so the device returns an update token for the activity you just started.dismissal-dateis only emitted on anend.relevance-scoreis clamped to[0, 1].- Each APNs request gets a distinct id, so retries and repeat sends are never coalesced by Apple as duplicates.
- Expiration defaults to 24 hours from send.
- When you omit
stale_dateon a start or update, the activity's registered stale time is injected for you. - Alert
titleandbodyare rendered through Liquid, capped at 512 and 2048 characters respectively.
Lifecycle windows
Registration timestamps, not send times, drive everything:
| Window | Duration | Meaning |
|---|---|---|
| Active | 8 hours from registration | start and update will target the activity |
| Stale | a further 4 hours | Only end will target it |
| Total | 12 hours | After this the activity is unreachable |
started_at is written once, at first registration, and cannot be refreshed by re-registering.
Dead tokens are handled differently depending on kind: a dead push-to-start token is deleted, while a dead update token ends that activity. Neither marks the device itself as unreachable, so a stale Live Activity token never costs you a push subscription.
Limits
| Limit | Value |
|---|---|
| Devices reached per send request | 2000; over that, the send is truncated |
| Target ids accepted per request | 20,000 |
Message name | 128 characters |
| Allowed events | start, update, end |
| Token shape | Hex, even length, 64–512 characters |
| Rate limit on the sending routes | 60 requests per 60 seconds per app, 429 with Retry-After: 60 |
| Idempotency window | 30 days |
| Liquid caps on alert copy | 512 (title) / 2048 (body) |
Honest caveats:
- The 2000-device fan-out cap is not surfaced in the response of the sending routes. A start against a segment larger than 2000 devices silently reaches the first 2000. If you need broader reach, split the audience and send in batches, respecting the 60-per-minute throttle.
- Nothing checks Apple's 4 KB Live Activity payload ceiling before sending. An oversized
event_updateswill be rejected by APNs, not by OpenPush. - The
/v1device-side routes are not rate limited; only the sending routes are. - There is no cap on concurrent activities per device enforced by OpenPush.
- Live Activity records are excluded from exports — see Import and export.
The Android analogue
Android has no ActivityKit and no true Live Activity. The Android SDK ships a Live Updates module that renders an ongoing, updatable notification from an FCM data payload keyed live_notification, with promotion to the status-bar chip where the OS supports it. The SDK exposes OpenPushLiveUpdates.handle(...), a capability check, and a helper to open the system promotion settings; Unity mirrors these under OpenPush.LiveUpdates.
On the server side, be aware of the shape of what exists: there is no /v1 route that sends an Android live notification, and the sender that does exist accepts a single template key, progress. Treat Android live updates as a client-side rendering capability you can drive from your own data payloads via an ordinary push, not as a server feature with parity to iOS Live Activities.
FAQ
Why does my start return "no device has registered a push-to-start token"?
Nobody in the target audience has completed push-to-start registration for that activity type on iOS 17.2+. Confirm the SDK setup call runs, that the activity type string matches your ActivityAttributes type name exactly, and that the devices are on a supported OS version.
Can I update an activity after it ended? No. Ending is final — the error message says so explicitly. Start a new activity with a new id.
Why did my update stop working after a few hours?
Activities are targetable for eight hours from registration, then for four more hours by end only. The clock starts at registration and cannot be extended.
Does the sending API accept OneSignal's full Live Activity body?
It accepts the field names listed above and silently ignores everything else. Fields like ios_interruption_level and apns_push_type_override have no effect here.
Can I send a Live Activity from a journey?
No. The send_live_activity journey node exists for draft design only and cannot go live — see Journeys.
Related
OneSignal is a trademark of OneSignal, Inc. OpenPush is an independent project and is not affiliated with, endorsed by, or sponsored by OneSignal, Inc. References to OneSignal are for identification and interoperability purposes only.