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

```
X-OP-SDK-Key: <SDK key>
```

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-Key` header 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

```bash
curl -X POST https://app.openpush.ai/v1/apps/app_3f9c/live-activities \
  -H "X-OP-SDK-Key: $OP_SDK_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "token": "9f8c1d0a4b7e2f36c5108ad9b3e7f21c4a6d90e8b1f27c34a5d6e0f81b2c3d4e",
    "activity_id": "order-58213",
    "activity_type": "DeliveryAttributes",
    "update_token": "3a7f21c4a6d90e8b1f27c34a5d6e0f81b2c3d4e59f8c1d0a4b7e2f36c5108ad9"
  }'
```

#### Example response

```json
{
  "id": "lact_01hq8b",
  "app": "app_3f9c",
  "activity_id": "order-58213",
  "created": true,
  "started_at": 1756598400.0,
  "stale_at": 1756627200.0,
  "active_until": 1756627200.0,
  "note": "updates are accepted for 8h from started_at, an end for 4h after that"
}
```

`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

```bash
curl -X POST https://app.openpush.ai/v1/apps/app_3f9c/live-activities/order-58213/end \
  -H "X-OP-SDK-Key: $OP_SDK_KEY" \
  -H "Content-Type: application/json" \
  -d '{"token": "9f8c1d0a4b7e2f36c5108ad9b3e7f21c4a6d90e8b1f27c34a5d6e0f81b2c3d4e"}'
```

#### Example response

```json
{"app": "app_3f9c", "activity_id": "order-58213", "ended": true}
```

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

```bash
curl -X POST https://app.openpush.ai/v1/apps/app_3f9c/live-activities/push-to-start \
  -H "X-OP-SDK-Key: $OP_SDK_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "token": "9f8c1d0a4b7e2f36c5108ad9b3e7f21c4a6d90e8b1f27c34a5d6e0f81b2c3d4e",
    "activity_type": "DeliveryAttributes",
    "push_to_start_token": "b2c3d4e59f8c1d0a4b7e2f36c5108ad93a7f21c4a6d90e8b1f27c34a5d6e0f81"
  }'
```

#### Example response

```json
{
  "id": "pts_01hq8c",
  "app": "app_3f9c",
  "activity_type": "DeliveryAttributes",
  "created": true
}
```

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

```bash
curl -X POST https://app.openpush.ai/v1/apps/app_3f9c/live-activities/push-to-start/remove \
  -H "X-OP-SDK-Key: $OP_SDK_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "token": "9f8c1d0a4b7e2f36c5108ad9b3e7f21c4a6d90e8b1f27c34a5d6e0f81b2c3d4e",
    "activity_type": "DeliveryAttributes"
  }'
```

#### Example response

```json
{"ok": true}
```

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

```bash
curl -X POST https://app.openpush.ai/v1/apps/app_3f9c/live-activities/order-58213/click \
  -H "X-OP-SDK-Key: $OP_SDK_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "token": "9f8c1d0a4b7e2f36c5108ad9b3e7f21c4a6d90e8b1f27c34a5d6e0f81b2c3d4e",
    "notification_id": "msg_01hq8d"
  }'
```

#### Example response

```json
{"ok": true}
```

#### 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](#unknown-fields).
- **Unsupported fields** include `is_ios`, `isAndroid`, `ios_interruption_level`, `apns_push_type_override`, `subtitle`, and `custom_data`.
- **The two routes disagree on the response key.** The start route returns `notification_id`; the update-and-end route returns `id`.
- **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:

```
Authorization: Key <REST API key>
X-OP-API-Key: <REST API key>
```

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

```json
{"errors": ["use exactly one targeting method"]}
```

### 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_id` is stored against `(app, key)`.
- A repeat of the same key returns `201` with the **original** `notification_id` and the header `Idempotent-Replayed: true`. Nothing is sent again.
- Keys are retained for **30 days**, then pruned.

#### Example request

```bash
curl -X POST https://app.openpush.ai/apps/app_3f9c/activities/activity/DeliveryAttributes \
  -H "Authorization: Key $OP_REST_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "event": "start",
    "activity_id": "order-58213",
    "name": "Order 58213 out for delivery",
    "event_attributes": {"orderNumber": "58213", "storeName": "Bridge Road"},
    "event_updates": {"status": "out_for_delivery", "etaMinutes": 24},
    "headings": {"en": "On the way"},
    "contents": {"en": "Arriving in about 24 minutes"},
    "priority": 10,
    "ios_relevance_score": 0.8,
    "stale_date": 1756627200,
    "idempotency_key": "0f2a5c31-9d64-4f0e-b8b1-2f7d5c9a41e0",
    "include_aliases": {"external_id": ["u-91422"]}
  }'
```

#### Example response

`201 Created`:

```json
{"notification_id": "msg_01hq8d"}
```

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

```bash
curl -X POST https://app.openpush.ai/apps/app_3f9c/live_activities/order-58213/notifications \
  -H "Authorization: Key $OP_REST_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "event": "update",
    "name": "Order 58213 ETA 8 min",
    "event_updates": {"status": "nearby", "etaMinutes": 8},
    "headings": {"en": "Almost there"},
    "contents": {"en": "Arriving in about 8 minutes"},
    "priority": 5
  }'
```

#### Example request — end

```bash
curl -X POST https://app.openpush.ai/apps/app_3f9c/live_activities/order-58213/notifications \
  -H "Authorization: Key $OP_REST_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "event": "end",
    "name": "Order 58213 delivered",
    "event_updates": {"status": "delivered", "etaMinutes": 0},
    "headings": {"en": "Delivered"},
    "contents": {"en": "Left at your front door"},
    "dismissal_date": 1756601000
  }'
```

#### Example response

`201 Created`:

```json
{"id": "msg_01hq8e"}
```

> **Response key differs by route.** The start route returns `notification_id`; this route returns `id`. 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

```
push-to-start token registered  ──►  remote start  ──►  updates (8h)  ──►  end (up to 12h)
device-side start ──► update token registered ──┘
```

**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`:

```json
{"openpush": {"notification_id": "msg_01hq8d", "activity_id": "order-58213"}}
```

`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.liveactivity` suffix.
- Priority defaults to **10** for `start` and `end`, and **5** for `update`. An explicit `priority` in the body wins.
- `apns-expiration` is 24 hours out by default.
- `apns-id` is derived per row, so every device's request is distinct even within one fan-out.
- `aps` carries: a **server-generated `timestamp`** (the platform discards non-increasing timestamps, so a caller-supplied one is not accepted), `event` as `start`/`update`/`end`, and a required non-empty `content-state`.
- On a `start`, `aps` additionally carries `input-push-token: 1`, `attributes-type` (the path `activity_type`), and `attributes`.
- `stale-date` is included when you send one; if you omit it on a `start` or `update`, the activity's registered stale date is injected.
- `dismissal-date` is included on `end` only.
- `alert: {title, body}` is included when either is non-empty; `sound` and a `relevance-score` clamped 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_attributes` and `event_updates` are 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-state` is 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_type` is not validated.** It is taken from the path verbatim and forced as the APNs `attributes-type`. A typo produces an activity iOS cannot decode, with no server-side error.
- **`stale_at` on 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.

---

## Related

- [Live Activities guide](../guides/live-activities.md)
- [iOS SDK](../guides/sdk-ios.md)
- [APNs setup](../guides/platform-setup-apns.md)
- [Subscriptions and users API](03-subscriptions-users.md)
- [API overview](00-overview.md)
