> ## Documentation Index
> Fetch the complete documentation index at: https://285e39fd5e337e58f16290.sightscreen.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Live Activity platform reference

> How iOS Live Activities work at the platform level — architecture, token lifecycle, APNs payloads, and production gotchas. Read this before touching LA code.

Everything you need to know about iOS Live Activities before working on the Sightscreen LA system. Synthesized from Apple WWDC sessions, developer forums, and hard-won production experience.

<Note>
  This page covers the **platform fundamentals**. For how Sightscreen uses Live Activities, see [Live Activity lifecycle](/business-flows/live-activity-lifecycle) and [iOS Live Activities](/ios/live-activities).
</Note>

## Architecture

Live Activities involve **four actors** working together.

### The four layers

**Your app (SwiftUI / ActivityKit)**

* Requests Live Activities via `Activity.request(attributes:content:pushType:.token)`
* Observes push tokens via async sequences
* Sends tokens to your backend
* `activity.pushTokenUpdates` — async sequence for **update** tokens
* `Activity<T>.pushToStartTokenUpdates` — async sequence for **start** tokens (iOS 17.2+)
* `Activity<T>.activityUpdates` — observe all activities of a type
* Lives in your **main app target**, NOT the widget extension

**Widget extension (WidgetKit + SwiftUI)**

* Renders the Live Activity UI across Lock Screen, Dynamic Island, and StandBy
* `ActivityConfiguration` defines the widget
* Lock Screen: full rectangular area above notifications
* Dynamic Island Compact: two small areas flanking the pill
* Dynamic Island Minimal: single tiny indicator (when multiple activities compete)
* Dynamic Island Expanded: long-press reveals larger view
* `context.isStale` — render fallback UI when data is outdated
* **Runs in a sandbox: NO network access, NO location updates**

**Your backend (Node.js / Go / etc)**

* Stores device tokens, sends APNs requests to start/update/end Live Activities
* Must handle token rotation and APNs error responses
* Stores push-to-start tokens AND update tokens per device
* **Must use token-based auth (JWT) — certificate auth NOT supported**
* **Must use HTTP/2 + TLS 1.2+**
* Sends to `api.push.apple.com:443` (prod) or `api.sandbox.push.apple.com:443` (dev)
* Topic format: `{bundleId}.push-type.liveactivity`

**Apple Push Notification service (APNs)**

* Routes push payloads from your server to the device
* Wakes the widget extension to re-render
* Priority 5 (low) = opportunistic delivery, **no budget limit**
* Priority 10 (high) = immediate delivery, **budget-limited**
* 4KB max payload size
* `NSSupportsLiveActivitiesFrequentUpdates` bypasses some throttling

### Data flow

```mermaid theme={null}
sequenceDiagram
    participant App as iOS App
    participant AKit as ActivityKit
    participant APNs as APNs
    participant Server as Backend

    App->>AKit: Activity.request(pushType: .token)
    AKit->>APNs: Request push token
    APNs-->>AKit: Unique push token
    AKit-->>App: pushTokenUpdates fires
    App->>Server: POST token to backend
    Server->>APNs: HTTP/2 POST with payload
    APNs->>AKit: Deliver to device
    AKit->>AKit: Widget extension re-renders
```

***

## Token lifecycle

This is the part Apple's docs barely cover. There are **two distinct token types** with completely different lifecycles, and a fundamental chicken-and-egg problem your backend must solve.

### Push-to-start token

| Property       | Value                                                                            |
| -------------- | -------------------------------------------------------------------------------- |
| Purpose        | Start a Live Activity remotely (even if app is terminated)                       |
| Availability   | iOS 17.2+                                                                        |
| Async sequence | `Activity<T>.pushToStartTokenUpdates`                                            |
| Lifetime       | Persists across app launches. May rotate (rare). Re-observe on every app launch. |

<Warning>
  **iOS 17 gotcha:** On iOS 17, the push-to-start token is nearly impossible to get reliably — delete app + reboot + fresh install was the only way. iOS 18+ works reliably. Target iOS 18+ for push-to-start features.
</Warning>

<Warning>
  **Double-fire:** `pushToStartTokenUpdates` fires TWICE after a server-side start — once with the real start token, then again with the SAME value after the LA is created. Deduplicate by caching the last sent token.
</Warning>

* Can **ONLY start** activities — cannot update or end them
* One token per ActivityAttributes TYPE, not per activity instance

### Update token

| Property       | Value                                                                                   |
| -------------- | --------------------------------------------------------------------------------------- |
| Purpose        | Update or end an existing Live Activity                                                 |
| Availability   | iOS 16.1+                                                                               |
| Async sequence | `activity.pushTokenUpdates` (on the Activity instance)                                  |
| Lifetime       | **Expires after 8 hours** (aligned with LA max lifespan). Unique per activity instance. |

<Warning>
  **Never read synchronously:** Don't access `activity.pushToken` after creation — it's nil. Must use the async sequence `pushTokenUpdates`.
</Warning>

* Token MAY change mid-activity (Apple says so, rarely observed in practice)
* When token changes, app gets foreground runtime to handle it
* You MUST invalidate old token on server when new one arrives

### The stale token problem

Your backend needs a valid push-to-start token to start a Live Activity. But the token might have rotated since the app last sent it. The app might be terminated, so it can't proactively tell you.

<Steps>
  <Step title="Layer 1 — Best case (~60-70% reliable)">
    Silent push (`content-available: 1`) wakes app in background. App sends fresh push-to-start token. LA starts seamlessly. iOS is extremely stingy about honoring silent pushes — considers battery, user engagement, frequency.
  </Step>

  <Step title="Layer 2 — Fallback (~95% reliable)">
    Backend detects APNs rejection (410/400). Sends visible notification. User taps. App opens. Fresh token sent. LA starts. 2-3 second delay but user barely notices since they just tapped.
  </Step>

  <Step title="Layer 3 — Passive (100% when user opens app)">
    User opens app naturally. `didFinishLaunchingWithOptions` fires. Fresh token sent. Backend starts LA. Depends on user behavior but great as supplementary strategy.
  </Step>
</Steps>

***

## Backend integration

### Authentication (.p8 key / JWT)

1. Generate a `.p8` key in Apple Developer Portal (Keys → Add Key → enable APNs)
2. Note your **Key ID** (10 char), **Team ID** (from Membership), and **Bundle ID**
3. JWT Header: `{"alg": "ES256", "kid": "YOUR_KEY_ID"}`, Payload: `{"iss": "YOUR_TEAM_ID", "iat": unix_timestamp}`
4. Sign with `.p8` private key using ES256
5. JWT is valid for **1 hour** — cache and refresh before expiry

<Warning>
  Certificate-based auth (.p12 / .pem) does NOT work with Live Activities. Only token-based (.p8) auth is supported. This fails silently.
</Warning>

### APNs connection requirements

| Requirement  | Value                               |
| ------------ | ----------------------------------- |
| Protocol     | HTTP/2 (mandatory)                  |
| TLS          | 1.2 or later                        |
| Production   | `api.push.apple.com:443`            |
| Sandbox      | `api.sandbox.push.apple.com:443`    |
| Push type    | `liveactivity`                      |
| Topic format | `{bundleId}.push-type.liveactivity` |
| Max payload  | 4KB                                 |
| Auth header  | `bearer {JWT}`                      |

### APNs error handling

| Code | Meaning                         | Action                                                                        |
| ---- | ------------------------------- | ----------------------------------------------------------------------------- |
| 200  | Success                         | No action needed                                                              |
| 400  | Bad request — malformed payload | Log and fix. Check `attributes-type` matches Swift struct name exactly.       |
| 403  | Auth error — invalid JWT        | Regenerate JWT. Verify .p8 key, Key ID, Team ID.                              |
| 404  | Invalid path or push token      | Token is invalid. Remove from database.                                       |
| 410  | Device token no longer active   | Token expired or LA dismissed. Remove from DB. Trigger fallback notification. |
| 429  | Rate limited by APNs            | Back off. Reduce high-priority push frequency.                                |

***

## APNs payloads

### Start (push-to-start)

Remotely starts a new Live Activity on the device, even if the app is terminated. Sent to the **push-to-start token**.

```json theme={null}
{
  "aps": {
    "timestamp": 1685952000,
    "event": "start",
    "content-state": {
      "teamOneScore": 0,
      "teamTwoScore": 0
    },
    "attributes-type": "SportsActivityAttributes",
    "attributes": {
      "gameName": "IND vs AUS",
      "gameNumber": "IPL Match 12"
    },
    "alert": {
      "title": "IND vs AUS is LIVE",
      "body": "Follow the match on your Lock Screen",
      "sound": "default"
    }
  }
}
```

<Warning>
  `attributes-type` **MUST** exactly match your Swift struct name. A mismatch causes a silent failure — no error, no LA, nothing.
</Warning>

### Update

Updates the dynamic content of an existing Live Activity. Sent to the **update token**.

```json theme={null}
{
  "aps": {
    "timestamp": 1685955600,
    "event": "update",
    "content-state": {
      "teamOneScore": 2,
      "teamTwoScore": 1
    },
    "stale-date": 1685959200,
    "alert": {
      "title": "WICKET!",
      "body": "Kohli dismissed for 47",
      "sound": "wicket.caf"
    }
  }
}
```

* `timestamp` **MUST increase** with each update — same or lower timestamp is silently ignored
* `stale-date` tells iOS when to show your `isStale` fallback UI
* Priority 5 = no budget limit, opportunistic delivery
* Priority 10 = immediate, but budget-limited
* `alert` is optional — use for important moments only
* `content-state` decoded with **default JSONDecoder** — no custom strategies, camelCase keys only

### End

Ends the Live Activity. Optionally sets final content and dismissal timing.

```json theme={null}
{
  "aps": {
    "timestamp": 1685962800,
    "event": "end",
    "dismissal-date": 1685966400,
    "content-state": {
      "teamOneScore": 4,
      "teamTwoScore": 3
    }
  }
}
```

* Without `dismissal-date`: removed from Dynamic Island immediately, stays on Lock Screen **up to 4 hours**
* `dismissal-date` in the past = removed immediately from everywhere
* `dismissal-date` up to 4 hours in future = stays on Lock Screen until then
* `dismissal-date` greater than 4 hours = **capped at 4 hours by iOS**
* Do NOT send `attributes` or `attributes-type` in end payloads (they're immutable)

### Testing with curl

```bash theme={null}
curl \
  --header "apns-topic: com.yourapp.bundle.push-type.liveactivity" \
  --header "apns-push-type: liveactivity" \
  --header "apns-priority: 10" \
  --header "authorization: bearer $AUTHENTICATION_TOKEN" \
  --data '{
    "aps": {
      "timestamp": '$(date +%s)',
      "event": "update",
      "content-state": {
        "teamOneScore": 2,
        "teamTwoScore": 1
      }
    }
  }' \
  --http2 https://api.sandbox.push.apple.com/3/device/$ACTIVITY_PUSH_TOKEN
```

***

## Production gotchas

Hard-won lessons ordered by severity.

### Task deallocation kills token observation

If your `pushTokenUpdates` observation Task gets garbage collected when the app backgrounds, you stop receiving token updates. Your Live Activities freeze showing stale data.

**Fix:** Store observation Tasks as instance properties on a singleton. Set up observation in `didFinishLaunchingWithOptions`, not when creating activities.

```swift theme={null}
// Correct — Task retained on singleton
@MainActor class LiveActivityManager {
  static let shared = LiveActivityManager()
  private var tokenTasks: [String: Task<Void, Never>] = [:]

  func observeTokens(for activity: Activity<MyAttrs>) {
    tokenTasks[activity.id] = Task { [weak self] in
      for await tokenData in activity.pushTokenUpdates {
        guard let self else { return }
        let token = tokenData.map {
          String(format: "%02x", $0)
        }.joined()
        await self.sendToBackend(token, activityId: activity.id)
      }
    }
  }
}
```

<Warning>
  A `Task` with no owner (not stored as a property) gets deallocated. The async sequence silently stops. No error, no crash — just frozen data on the lock screen.
</Warning>

### The double-fire confusion

`pushToStartTokenUpdates` fires TWICE after a server-side start: once with the real start token, then again with the SAME value after the LA is created. The second fire is NOT an update token.

**Fix:** Cache the token and deduplicate. Use `pushToStartTokenUpdates` ONLY for start tokens. Use `activity.pushTokenUpdates` (on the Activity instance from `activityUpdates`) for update tokens. These are two completely different async sequences.

### Timestamp must always increase

If you send an update with the same or lower timestamp as a previous update, iOS **silently ignores** it. No error, no log, nothing. Always use monotonically increasing timestamps.

### No network in widget extension

Live Activities render in a sandboxed widget extension. You **cannot** fetch remote images with AsyncImage, URLImage, or any network call.

**Fix:** Pre-download images in the main app. Store them in a shared App Group container. Load in the widget with `UIImage(contentsOfFile:)`.

### Budget throttling with priority 10

High-priority pushes are budget-limited. If you exceed the budget, updates are silently throttled. Cricket matches with frequent score changes can easily hit this.

**Fix:**

* Add `NSSupportsLiveActivitiesFrequentUpdates = YES` to Info.plist
* Use Priority 5 for routine updates (dot balls, singles)
* Reserve Priority 10 for wickets, boundaries, milestones
* Check `frequentPushesEnabled` — users can disable this independently

### Relevance score for multiple activities

If a user follows multiple matches simultaneously, iOS needs to know which one to show in the Dynamic Island.

**Fix:** Set `relevance-score` in your payload. Higher number = higher priority. Update dynamically — a close finish should score higher than a one-sided match.

### Debugging tips

* Use the **Console app** on Mac to view device logs
* Filter by processes: `liveactivitiesd`, `apsd`, `chronod`
* If APNs returns 200 but the LA doesn't update, check device logs for decode failures
* Use `JSONEncoder` with **default** encoding strategies to verify your `content-state` format

***

## Sightscreen playbook

How all of this maps to cricket live scores.

### Priority decision matrix

| Event            | Priority | Alert?      | Reason                                    |
| ---------------- | -------- | ----------- | ----------------------------------------- |
| Dot ball         | 5        | No          | Routine — don't drain budget              |
| Single/Double    | 5        | No          | Routine score update                      |
| Boundary (4)     | 5        | No          | Exciting but too frequent to buzz         |
| Six              | 5        | No          | Same — boundaries are routine in T20      |
| Wicket           | 10       | Yes + sound | Critical — fans need to know immediately  |
| 50/100 milestone | 10       | Yes         | Celebration moment                        |
| End of over      | 5        | No          | Summary update, not urgent                |
| Innings break    | 10       | Yes         | Major state change                        |
| Match result     | 10       | Yes         | The climax                                |
| Hat-trick        | 10       | Yes         | Historic — once every few hundred matches |
| On hat-trick     | 10       | Yes         | Next ball is the hat-trick attempt        |
| Maiden over      | 10       | Yes         | Rare in T20s — bowler dominance moment    |
| Batsman on fire  | 10       | Yes         | SR 200+ sustained                         |
| Bowler on fire   | 10       | Yes         | 3+ wickets tight economy                  |

### Match lifecycle phases

<Accordion title="Pre-match (Orchestrator detects match going live)">
  Orchestrator polls fixtures table every 30s. Detects matches transitioning to live status. For each match with followers: sends push-to-start to all follower devices, creates pending live-activity records, subscribes event bus.
</Accordion>

<Accordion title="During match (Event Bus pipeline)">
  ScorePoller fetches from SportMonks every 5s. MatchEventBus runs detection pipeline (Tier 0 + Tier 1 detectors, cooldown filter, staleness filter). LACoordinator sends APNs batch to all active tokens. Priority 5 for routine, Priority 10 for alert-worthy events.
</Accordion>

<Accordion title="Match end">
  LACoordinator sends `event: "end"` with `dismissal-date` = now + 15 minutes. All live-activity records marked as ended. Match-follows cleaned up. Event bus unsubscribed.
</Accordion>

<Accordion title="Rain delay / suspension">
  Event bus detects status transition to suspended. Sets `suspendedAt` flag on live-activity records to prevent iOS stale check from ending LA. Event bus reduces polling to 60s. On resume: clears flag, sends score refresh at P5.
</Accordion>

***

## Sources

<Accordion title="Apple official">
  1. [WWDC23: Update Live Activities with push notifications](https://developer.apple.com/videos/play/wwdc2023/10185/)
  2. [WWDC23: Meet ActivityKit](https://developer.apple.com/videos/play/wwdc2023/10184/)
  3. [WWDC24: Broadcast updates to your Live Activities](https://developer.apple.com/videos/play/wwdc2024/10069/)
  4. [WWDC23: Design dynamic Live Activities](https://developer.apple.com/videos/play/wwdc2023/10194/)
  5. [ActivityKit documentation](https://developer.apple.com/documentation/activitykit)
  6. [Starting and updating Live Activities with push notifications](https://developer.apple.com/documentation/activitykit/starting-and-updating-live-activities-with-activitykit-push-notifications)
  7. [Human Interface Guidelines: Live Activities](https://developer.apple.com/design/human-interface-guidelines/live-activities)
  8. [Sending push notifications using command-line tools](https://developer.apple.com/documentation/usernotifications/sending-push-notifications-using-command-line-tools)
  9. [Establishing a token-based connection to APNs](https://developer.apple.com/documentation/usernotifications/establishing-a-token-based-connection-to-apns)
  10. [Sending notification requests to APNs](https://developer.apple.com/documentation/usernotifications/sending-notification-requests-to-apns)
</Accordion>

<Accordion title="Community deep dives">
  * [Christian Selig: Server side Live Activities guide](https://christianselig.com/2024/09/server-side-live-activities/) — Push-to-start handoff, iOS 17 vs 18 gap, double-fire, Node.js library landscape
  * [Daniel Onadipe: Task lifecycle management deep dive](https://medium.com/@onadipedaniel/solving-live-activities-push-token-issues-a-deep-dive-into-task-lifecycle-management-56332da4feb4) — The definitive post on Task deallocation killing token observation
  * [Braze SDK: Live Activities docs](https://www.braze.com/docs/developer_guide/live_notifications/live_activities) — Confirms 8-hour token expiry, documents `NSSupportsLiveActivitiesFrequentUpdates`
</Accordion>

<Accordion title="Apple Developer Forums">
  * [Thread 740791](https://developer.apple.com/forums/thread/740791) — Token change never observed in production
  * [Thread 682939](https://developer.apple.com/forums/thread/682939) — Push tokens don't expire but can change on reinstall/restore/OS update
  * [Thread 671310](https://developer.apple.com/forums/thread/671310) — Request fresh tokens on every launch
</Accordion>
