# 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](#lifecycle-windows)), or for anything Android-first — Android has no ActivityKit equivalent, only a [loosely analogous ongoing notification](#the-android-analogue).

## 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](platform-setup-apns.md) |
| 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](sdk-unity.md).

## 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:

```swift
OpenPush.LiveActivities.setupDefault(
    options: LiveActivitySetupOptions(enablePushToStart: true, enablePushToUpdate: true))
```

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:

```swift
OpenPush.LiveActivities.enter(activityId, withToken: pushToken, activityType: "DeliveryAttributes") { _ in }
```

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](#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

```bash
curl -X POST https://app.openpush.ai/apps/app_3f9c2b/activities/activity/DeliveryAttributes \
  -H "Authorization: Key $OP_REST_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "event": "start",
    "activity_id": "order_88214",
    "name": "Order 88214 out for delivery",
    "event_attributes": {"orderNumber": "88214", "storeName": "Bakery on Main"},
    "event_updates": {"stage": "out_for_delivery", "etaMinutes": 22},
    "headings": {"en": "On the way"},
    "contents": {"en": "Arriving in about 22 minutes"},
    "included_segments": ["seg_01hq8n4t2v"],
    "priority": 10,
    "idempotency_key": "5f7a2c1e-9b40-4a2d-8c31-6e0b7d94a1f2"
  }'
```

```json
{"notification_id": "msg_01hq8n4t2v"}
```

`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](#idempotency). |
| 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`, and `custom_data` are **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

```bash
curl -X POST https://app.openpush.ai/apps/app_3f9c2b/live_activities/order_88214/notifications \
  -H "Authorization: Key $OP_REST_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "event": "update",
    "name": "Order 88214 nearby",
    "event_updates": {"stage": "arriving", "etaMinutes": 3},
    "headings": {"en": "Almost there"},
    "contents": {"en": "Your order is 3 minutes away"},
    "priority": 10
  }'
```

```json
{"id": "msg_01hq8n5x9k"}
```

`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](#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:

```swift
// In the widget
Text(context.state.stage).openpushWidgetURL(activityId: activityId, deepLink: myURL)

// In the app, when the URL arrives
if let original = OpenPush.LiveActivities.trackClickAndReturnOriginal(url) {
    route(to: original)
}
```

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_key` as a **UUID**.
- A first use records the key against the resulting message id.
- A replay within **30 days** returns `201` with the original `notification_id` and the header `Idempotent-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](security-and-limits.md).

## APNs behaviour

Worth knowing when you are debugging why something did or did not appear:

- Live Activity pushes are sent with `apns-push-type: liveactivity` and a topic of your bundle id plus the suffix `.push-type.liveactivity`.
- **Priority defaults differ by event**: `start` and `end` default to 10, `update` defaults to 5. An explicit `priority` in 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 `timestamp` in 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-state` is required and must be non-empty. On a start, `attributes-type`, `attributes`, and `input-push-token` are also included so the device returns an update token for the activity you just started.
- `dismissal-date` is only emitted on an `end`.
- `relevance-score` is 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_date` on a start or update, the activity's registered stale time is injected for you.
- Alert `title` and `body` are rendered through [Liquid](personalization.md), 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_updates` will be rejected by APNs, not by OpenPush.
- The `/v1` device-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](import-export.md).

## 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](journeys.md).

## Related

- [iOS SDK](sdk-ios.md)
- [Unity SDK](sdk-unity.md)
- [APNs setup](platform-setup-apns.md)
- [Migrating from OneSignal](migrate-from-onesignal.md)
- [Security and limits](security-and-limits.md)
- [Personalization](personalization.md)

---

*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.*
