Live Activities
A Live Activity is the iOS surface that shows live state on the Lock Screen and in the Dynamic Island — a delivery ETA, a match score, a build progress bar. ActivityKit gives each running activity its own push token, and updates are pushed to that token rather than to the device's ordinary notification token.
OpenPush splits this across two route families, and they do different jobs:
| Family | Auth | Side | What it does |
|---|---|---|---|
Native /v1 routes | X-OP-SDK-Key | Device | Register an activity's update token, register a push-to-start token, end an activity locally, report a tap. |
| Two server send routes | Authorization: Key or X-OP-API-Key | Server | Start, update and end activities across an audience. |
You need both. The device registers what it has; your backend pushes to it. There is no native /v1 route that sends a Live Activity; the server send routes are the send path.
All examples use https://app.openpush.ai, the OpenPush API base URL.
Part 1 — Native device routes
Five routes under /v1, all authenticated with the app's SDK key:
Code
Every one of them identifies the device by its ordinary push token in the token body field. The device must already be registered as a subscription; if it is not, you get a 404.
Errors use the standard body shape: {"detail": "<message>"}.
Shared demo apps only. If your app is a shared sample app, these five routes additionally require an
X-OP-Device-Keyheader identifying the linked device, because every tenant of a shared app holds the same SDK key. On an ordinary app the header is not used.
POST /v1/apps/{app_id}/live-activities
Registers (or refreshes) the update token for one running activity. Call it from your Activity.pushTokenUpdates handler.
Body
| Parameter | Type | Required | Description |
|---|---|---|---|
token | string | yes | The device's ordinary push token — the one it registered as a subscription. |
activity_id | string | yes | Your identifier for this activity instance. Must be non-empty. It is what update and end calls address. |
activity_type | string | no | The ActivityKit attributes type name, e.g. DeliveryAttributes. Used to match push-to-start registrations and to disambiguate an id used more than once. |
update_token | string | yes | The ActivityKit push token for this activity: hex, even length, 64–512 characters. |
stale_at | number | no | Unix seconds. Silently clamped — see below. |
Registration is keyed on (app, subscription, activity id), so the same activity id on two of a user's devices produces two independent rows and both get pushed.
Re-registering an existing activity refreshes update_token, fills in activity_type if it was blank, and clears any previous end. It does not move started_at or stale_at — a token rotation cannot extend the activity's window.
stale_at is clamped to started_at + 8 hours on creation. You can ask for a shorter stale date; the platform ends the activity after its eight-hour active window.
Example request
Code
Example response
Code
created is false when the call refreshed an existing registration.
Errors
| Status | Body | Cause |
|---|---|---|
| 400 | {"detail": "activity_id is required — an activity with no id cannot be updated, ended or found by a fan-out"} | Missing or blank activity_id. |
| 400 | {"detail": "…"} | Update token is not hex, is odd-length, or is outside 64–512 characters. |
| 400 | {"detail": "…"} | stale_at is a boolean, an object, or unparseable. |
| 400 | {"detail": "stale_at must be a finite positive unix timestamp, got -1"} | Zero, negative, NaN or infinite. |
| 403 | {"detail": "live activity 'order-58213' belongs to another device — an activity id is not a capability"} | The id is already held by a different device on a shared app. |
| 404 | {"detail": "unknown device token for this app — register the device before its Live Activities"} | The token matches no subscription. |
POST /v1/apps/{app_id}/live-activities/{activity_id}/end
The device reports that it has ended this activity locally. Stamps the row as ended for that device only — the row is kept, never deleted.
Body
| Parameter | Type | Required | Description |
|---|---|---|---|
token | string | yes | The device's ordinary push token. |
Example request
Code
Example response
Code
Errors
| Status | Body | Cause |
|---|---|---|
| 404 | {"detail": "no live activity 'order-58213' on this device"} | This device has no activity by that id. An id belonging to another device produces the same answer. |
| 404 | {"detail": "unknown device token for this app"} | The token matches no subscription. |
POST /v1/apps/{app_id}/live-activities/push-to-start
Registers a push-to-start token, which lets your server begin an activity that does not exist yet. iOS 17.2 and later.
Body
| Parameter | Type | Required | Description |
|---|---|---|---|
token | string | yes | The device's ordinary push token. |
activity_type | string | yes | The attributes type this token can start. The platform issues one push-to-start token per type. |
push_to_start_token | string | yes | Hex, even length, 64–512 characters. |
One row per (app, subscription, activity type). A later registration for the same type replaces the token.
A push-to-start token has no expiry window — it is a standing capability, unlike an update token, which ages out with its activity.
Example request
Code
Example response
Code
Errors
| Status | Body | Cause |
|---|---|---|
| 400 | {"detail": "…"} | Missing or blank type. |
| 400 | {"detail": "…"} | Bad token shape. |
| 404 | {"detail": "unknown device token for this app"} | No such subscription. |
POST /v1/apps/{app_id}/live-activities/push-to-start/remove
Removes this device's push-to-start token for one activity type. Call it when the user turns the feature off, so remote starts stop reaching them.
Body
| Parameter | Type | Required | Description |
|---|---|---|---|
token | string | yes | The device's ordinary push token. |
activity_type | string | yes | The type whose token should be removed. |
Example request
Code
Example response
Code
Errors
| Status | Body | Cause |
|---|---|---|
| 400 | {"detail": "activity_type is required"} | Missing or blank type. |
| 404 | {"detail": "no push-to-start token for 'DeliveryAttributes' on this device"} | Nothing registered for that type. |
| 404 | {"detail": "unknown device token for this app"} | No such subscription. |
POST /v1/apps/{app_id}/live-activities/{activity_id}/click
Attributes a tap on a Live Activity back to the push that produced it.
Body
| Parameter | Type | Required | Description |
|---|---|---|---|
token | string | yes | The device's ordinary push token. |
notification_id | string | no | The id returned by the start or update call. Supply it when you have it — it is the exact attribution. |
activity_type | string | no | Narrows the match when one activity id has been used under more than one type. |
Without notification_id, the most recent Live Activity push for this activity id and device is used.
Each call increments the message's total click count, and stamps that device's delivery record with the click time only if it was not already stamped. Total taps are therefore counted while the funnel keeps distinct-device semantics.
Example request
Code
Example response
Code
Errors
| Status | Body | Cause |
|---|---|---|
| 404 | {"detail": "no live activity push to attribute this click to"} | No matching Live Activity message was delivered to this device. |
| 404 | {"detail": "unknown device token for this app"} | No such subscription. |
Part 2 — Server send routes
Two routes that start, update and end Live Activities across an audience. They are mounted at the server root, not under /v1, and are included in the OpenAPI document.
Use the paths and body fields documented below. The two routes have their own response and error conventions:
- Unknown body keys return
400, including misspelled or unsupported fields — see Unknown fields. - Unsupported fields include
is_ios,isAndroid,ios_interruption_level,apns_push_type_override,subtitle, andcustom_data. - The two routes disagree on the response key. The start route returns
notification_id; the update-and-end route returnsid. - An audience above 2000 devices is truncated, and the truncation is not reported in the response.
- The pair is rate-limited to 60 requests per 60 seconds per app.
Check your payload field by field against the tables below before sending. These routes are the server send path.
Authentication
Either spelling works:
Code
Authorization also accepts the Basic, Bearer and bare-value forms. In every one of them the server takes the raw REST key as the literal text after the scheme word. Basic here is not RFC 7617: nothing is base64-decoded and no user:password pair is parsed, so a client sending genuine HTTP Basic credentials gets a 401. Send Authorization: Key <REST API key> unless you have a reason not to.
Error envelope
These two routes use their own shape — a list of strings, not a detail field, and not the journeys envelope either:
Code
Rate limit
60 requests per 60 seconds per app, shared across both routes. Over the limit you get 429 with Retry-After: 60. This is one of only two rate limits on the public API, and it applies to the compat routes only — the native /v1 routes above are not throttled.
Unknown fields
A misspelled or unsupported top-level body field returns 400 with an errors list.
For example, ios_interruption_level, apns_push_type_override, subtitle, and
custom_data are unsupported. idempotency_key is accepted on start only; an
update or end that includes it returns 400.
POST /apps/{app_id}/activities/activity/{activity_type} — start
Starts a Live Activity remotely on every targeted device that has registered a push-to-start token for {activity_type}.
Path parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
app_id | string | yes | The app id. |
activity_type | string | yes | The ActivityKit attributes type. It is used verbatim as the APNs attributes-type; there is no way to send a different one. |
Body
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
event | string | yes | — | Must be exactly "start". |
activity_id | string | yes | — | Non-blank, and must not contain /. This is the id later update and end calls address. |
event_attributes | object | yes | — | Non-empty. Becomes ActivityKit attributes — the immutable part of the activity. |
event_updates | object | yes | — | Non-empty. Becomes content-state — the part you change on every update. |
name | string | yes | — | Internal label for the send, ≤128 characters. Shows up in your message list. |
contents | object | yes | — | Language code to body text. Must contain a non-blank en. |
headings | object | yes | — | Language code to title text. Must contain a non-blank en. |
stale_date | number | no | registered stale date | Unix seconds. A value at or above 1e11 is rejected as milliseconds. |
dismissal_date | — | — | — | Rejected on a start. End-only. |
priority | int | no | 10 | Exactly 5 or 10. |
ios_relevance_score | number | no | — | Float in [0, 1]. Orders competing activities on the Lock Screen. |
ios_sound / sound | string | no | — | First non-blank of the two wins. |
idempotency_key | string | no | — | See below. |
Targeting — supply exactly one of:
| Field | Shape |
|---|---|
include_aliases | {"external_id": ["u-1", "u-2"]}. The label external_id resolves as the user's external id; subscription_id and openpush_id resolve as subscription ids; any other label is treated as a custom alias. |
include_subscription_ids | Array of subscription ids. |
included_segments | Array of segment names. excluded_segments may accompany it. |
filters | Array of filter objects using the segment filter language. relation is accepted as a synonym for op. |
Sending zero or two of them is 400 "use exactly one targeting method". Sending excluded_segments without included_segments is 400 "excluded_segments requires included_segments".
Pre-flight. Before anything is sent, the server checks that at least one targeted device holds a push-to-start token for this activity_type. If none does, the call fails with 400 rather than reporting a successful send that reaches nobody.
idempotency_key
This is the only idempotency mechanism in OpenPush, and it is a body field, not a header. There is no Idempotency-Key header anywhere in the API, and message creation does not accept an idempotency key at all.
- The value must be a UUID. Anything else is a
400. - On the first use, the resulting
notification_idis stored against(app, key). - A repeat of the same key returns
201with the originalnotification_idand the headerIdempotent-Replayed: true. Nothing is sent again. - Keys are retained for 30 days, then pruned.
Example request
Code
Example response
201 Created:
Code
A replay of the same idempotency_key returns the same body plus Idempotent-Replayed: true.
Errors
| Status | Body | Cause |
|---|---|---|
| 400 | {"errors": ["event must be \"start\" on this route"]} | Wrong or missing event. |
| 400 | {"errors": ["activity_id is required"]} / ["activity_id cannot contain '/'"] | Bad activity id. |
| 400 | {"errors": ["event_updates is required and must be a non-empty JSON object"]} | Missing or empty event_attributes / event_updates. |
| 400 | {"errors": ["contents must include an \"en\" entry"]} | No English entry. |
| 400 | {"errors": ["name must be 128 characters or fewer"]} | Name too long, or missing. |
| 400 | {"errors": ["stale_date must be a unix timestamp in seconds, not milliseconds"]} | Value ≥ 1e11. |
| 400 | {"errors": ["priority must be 5 or 10"]} | Anything else. |
| 400 | {"errors": ["ios_relevance_score must be between 0 and 1"]} | Out of range or unparseable. |
| 400 | {"errors": ["dismissal_date is not supported on a start"]} | Sent on a start. |
| 400 | {"errors": ["use exactly one targeting method"]} | Zero or several targeting fields. |
| 400 | {"errors": ["no device has registered a push-to-start token for activity_type 'DeliveryAttributes'"]} | Pre-flight found nobody startable. |
| 400 | {"errors": ["the request body must be a JSON object"]} | Non-JSON or non-object body. |
| 401 | {"errors": ["bad API key — send Authorization: Key <your app's REST API key> or X-OP-API-Key"]} | Missing or wrong key. |
| 403 | {"errors": ["…"]} | Cross-tenant access on a shared sample app. |
| 404 | {"errors": ["unknown app 'app_3f9c'"]} | No such app. |
| 429 | {"errors": ["too many Live Activity requests for this app"]} | Over 60/60 s. Retry-After: 60. |
POST /apps/{app_id}/live_activities/{activity_id}/notifications — update and end
Updates or ends an activity already running on devices.
Targeting is fixed to the activity_id in the path. There are no segments, aliases or filters on this route — everyone holding that activity gets the push.
Body
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
event | string | yes | — | "update" or "end". |
event_updates | object | yes | — | Non-empty. The new content-state. Required on an end too. |
name | string | yes | — | ≤128 characters. |
contents | object | no | — | If present, must contain a non-blank en. |
headings | object | no | — | Same rule. |
stale_date | number | no | — | Unix seconds. Accepted on both update and end. |
dismissal_date | number | no | — | Unix seconds. end only — sending it on an update is a 400. |
priority | int | no | 10 | 5 or 10. |
ios_relevance_score | number | no | — | 0–1. |
sound / ios_sound | string | no | — | First non-blank wins. |
idempotency_key is not accepted on this route. It is start-only; including it
on an update or end returns 400.
Example request — update
Code
Example request — end
Code
Example response
201 Created:
Code
Response key differs by route. The start route returns
notification_id; this route returnsid. Both are message ids.
Errors
| Status | Body | Cause |
|---|---|---|
| 400 | {"errors": ["event must be \"update\" or \"end\" on this route"]} | Wrong event. |
| 400 | {"errors": ["event_updates is required and must be a non-empty JSON object"]} | Missing content state. |
| 400 | {"errors": ["dismissal_date is supported only on an end"]} | Sent on an update. |
| 400 | {"errors": ["headings must include an \"en\" entry"]} | Supplied but missing English. |
| 400 | {"errors": ["the selected activity_id is registered with multiple activity types"]} | The id resolves to rows of more than one type. |
| 404 | {"errors": ["no live activity with that id is open on this app — an activity that has ended can never be updated again"]} | Nothing open by that id, or every holder is outside the delivery window. |
| 429 | {"errors": ["too many Live Activity requests for this app"]} | Over 60/60 s. |
Lifecycle
Code
Windows. From the moment an activity is registered:
| Window | Length | What it allows |
|---|---|---|
| Active | 8 hours | start and update pushes select the activity. |
| Stale | + 4 hours | end pushes still select it. |
| Total | 12 hours | After this, nothing selects it. |
These are enforced by target selection, so an update sent at hour nine simply reaches nobody and reports zero targets — it is not an error at the field level, it is a 404 from the update route because no open activity was selected.
Ending. A device-side end stamps the row ended for that device and keeps it; the row is never deleted. A remote end push closes it on the device. An activity that has ended can never be updated again — the message in the 404 says exactly that, deliberately.
Push-to-start tokens have no window. They persist until the device replaces them or you remove them.
Dead tokens are handled differently by kind. A push-to-start token rejected by APNs is deleted. A rejected update token ends that activity rather than marking the device unreachable — an expired Live Activity token says nothing about whether the device can still receive ordinary pushes, so the subscription is left alone.
Subscription lifecycle. When a subscription is retired, or superseded by a token refresh, every open activity on it is closed.
Id injection. Every start and update stamps an id block into both attributes and content-state:
Code
activity_id appears in the attributes block on a start. A legacy migration alias may
also appear in the payload; new integrations should read the openpush block.
APNs behaviour
What OpenPush sends over APNs, for reference when you are debugging on-device decoding:
apns-push-type: liveactivity, with the topic set to your bundle id plus the.push-type.liveactivitysuffix.- Priority defaults to 10 for
startandend, and 5 forupdate. An explicitpriorityin the body wins. apns-expirationis 24 hours out by default.apns-idis derived per row, so every device's request is distinct even within one fan-out.apscarries: a server-generatedtimestamp(the platform discards non-increasing timestamps, so a caller-supplied one is not accepted),eventasstart/update/end, and a required non-emptycontent-state.- On a
start,apsadditionally carriesinput-push-token: 1,attributes-type(the pathactivity_type), andattributes. stale-dateis included when you send one; if you omit it on astartorupdate, the activity's registered stale date is injected.dismissal-dateis included onendonly.alert: {title, body}is included when either is non-empty;soundand arelevance-scoreclamped to[0, 1]are included when supplied.- Liquid renders in the Live Activity title and body, with the same caps as a push: 512 characters for the title, 2048 for the body. Custom fields in
event_attributesandevent_updatesare delivered literally and never rendered.
Limits
| Limit | Value |
|---|---|
| Active / stale / total window | 8 h / 4 h / 12 h |
| Devices reached per compat request | 2000 |
| Explicit target id list | 20,000 ids, queried in chunks of 400 |
Message name | 128 characters |
| Token shape (update and push-to-start) | Hex, even length, 64–512 characters |
Allowed event values | start, update, end |
| Compat route rate limit | 60 requests / 60 s per app |
| Idempotency key retention | 30 days |
Native /v1 route rate limit | none |
Notes and gotchas
- The fan-out cap is silent on the compat routes. A request whose audience exceeds 2000 devices sends to 2000 of them and returns an ordinary
201. The truncation is recorded internally but is not surfaced in the response. Segment your audience yourself if it is larger than that. - Nothing checks the 4 KB payload ceiling. An oversized
content-stateis sent and rejected by APNs, not caught up front. Keep the state small. - Live Activity pushes do not produce confirmed-delivery receipts. The delivery record gets sent, accepted and error states only. Clicks are recorded, via the native click route above.
- Auto-end minutes. The console offers 5, 15, 30 and 60 minutes as auto-end options, but the server accepts any integer from 1 to 120.
activity_typeis not validated. It is taken from the path verbatim and forced as the APNsattributes-type. A typo produces an activity iOS cannot decode, with no server-side error.stale_aton the native route is clamped, not rejected. Ask for 24 hours, get 8.- Event streams and webhooks for Live Activity lifecycle are not implemented.
FAQ
Do I need push-to-start, or can I start activities in the app? Either. If the app starts the activity itself, it must call the native register route with the resulting update token before you can push to it. Push-to-start is for starting one when the app is not running — that requires a registered push-to-start token and iOS 17.2+.
Why did my update return 404 when the activity is clearly on screen? Three common causes: it is past the 8-hour update window; the device already reported the activity ended; or the activity was registered against a different app. An activity that has ended can never be updated again.
Can I address one specific device on an update? No. Update and end target every open row for that activity id. Use distinct activity ids per user if you need per-device control.
Is idempotency_key available on message sends?
Message creation uses an Idempotency-Key request header with a 24-hour retry window.
Live Activity start uses the body field idempotency_key with a 30-day replay window.
Update and end do not accept that body field.
How do I count taps?
Call the native click route from the app when the user taps into a Live Activity, passing the notification_id you stamped into the content state. Total taps accumulate; the per-device funnel records the first tap only.