Skip to main content
Tutorials

iOS Live Activities: ActivityKit and Dynamic Island on Windows

Last updated

iOS Live Activities use ActivityKit to put something that’s happening right now on the Lock Screen and in the Dynamic Island, such as a food delivery, a ride or a live score. You build the UI in a widget extension, then start, update and end the activity from the app with ActivityKit, and optionally update it from your server through APNs.

In this tutorial you add a food delivery Live Activity to a SwiftUI project called MyApp. You add the extension with kxbuild project add-target --type widget-live-activity, build the Lock Screen view and the three Dynamic Island presentations, start, update and end the activity from the app, and finally update it with an APNs push from your computer. Every step works on Windows.

The Live Activity UI lives in a widget extension, so it helps to read iOS widget tutorial with WidgetKit and SwiftUI, built on Windows first. Apple’s rules below follow the official documentation as of October 2026.

How do iOS Live Activities differ from widgets?

Both use SwiftUI for the UI and live in the same widget extension, but they update in completely different ways.

Widget Live Activity
Where it appears Home Screen, Lock Screen, Today View Lock Screen, Dynamic Island, StandBy, a paired Apple Watch and Mac
What updates it The system fetches data on a timeline The app updates it with ActivityKit, or your server sends a push
How long it lasts Always there Ends when the task ends, at most 8 hours
Best for Summary information you read at a glance Status that’s in progress and changes every few minutes
Network access Allowed in the timeline Not allowed, data comes only from the app or a push

Know these limits before you start.

Rule Details
iOS version ActivityKit is available from iOS 16.1, and the ActivityContent API used below needs iOS 16.2. The extension added in this tutorial requires iOS 17 or later
Dynamic Island Shown only on iPhones with a Dynamic Island (iPhone 14 Pro and later). Other models show only the Lock Screen view, which appears as a banner when the phone is unlocked
Duration An activity can run for up to 8 hours, then the system ends it and removes it from the Dynamic Island. It can stay on the Lock Screen for up to 4 more hours, so 12 hours at most on the Lock Screen
Data size The static and dynamic data together must be 4 KB or less, and so must each update and push
When to start Usually start it while the app is in the foreground. Updating and ending can happen in the background
User setting Users can turn off Live Activities for an app in Settings

Step 1: How do you add the Live Activity extension?

Use either approach.

  • VS Code extension. In the KXBuild view, right-click the MyApp target, choose KXBuild: Add Target…, pick the widget type that includes a Live Activity, and keep the default name MyAppWidget. A Live Activity doesn’t need to share data with the app, so leave the App Group empty.
  • Command line. Run the command below in the project folder.
1kxbuild project add-target -P .\MyApp.xcodeproj --type widget-live-activity --name MyAppWidget

widget-live-activity adds a widget extension that contains both a regular widget and a Live Activity. The extension’s Bundle ID is the app’s Bundle ID plus a dot and the lowercase name, here com.example.myapp.myappwidget. The command output reminds you to register an App ID for it. For every type and option, see project add-target in the kxbuild reference and Add a target.

If you already added a widget extension by following the widget tutorial, you don’t need a second extension. Add the Live Activity code below to the existing extension, list it in the body of MyAppWidgetBundle.swift, then turn on Supports Live Activities for the app as described in the next section.

Which files does the extension generate?

A new MyAppWidget folder appears in the project folder.

File Purpose
MyAppWidgetBundle.swift Extension entry point, lists both the widget and the Live Activity
MyAppWidget.swift The regular widget. Remove it from the bundle if you don’t need it
MyAppWidgetLiveActivity.swift The Live Activity’s data definition and UI
Info.plist Declares this as a WidgetKit extension
Assets.xcassets Accent and background colors

MyAppWidgetBundle.swift already lists both, so leave it as is.

1@main
2struct MyAppWidgetBundle: WidgetBundle {
3    var body: some Widget {
4        MyAppWidget()
5        MyAppWidgetLiveActivity()
6    }
7}

Step 2: Turn on Supports Live Activities

The app must set NSSupportsLiveActivities to YES in its Info.plist, or starting a Live Activity fails. When you add the extension with widget-live-activity, the app target’s build settings already include INFOPLIST_KEY_NSSupportsLiveActivities = YES. Search for NSSupportsLiveActivities on the Build Settings page of the Project Settings Editor to see it.

If your project maintains its own Info.plist file (for example one moved over from an older Xcode project), add the key below to the app’s Info.plist. You can also edit it in the Property List Editor.

1<key>NSSupportsLiveActivities</key>
2<true/>

Add this key to the app’s Info.plist, not the extension’s.

Step 3: How do you define the Live Activity’s data?

A Live Activity’s data has two parts, described by one struct that conforms to ActivityAttributes.

  • Static data goes in the struct’s properties, such as the order number and shop name. You set it when the activity starts and can’t change it later.
  • Dynamic data goes in the nested ContentState, such as the status, progress and estimated arrival time. Each update replaces the whole state.

The app needs this type to start the activity, and the extension needs it to display it, so put an identical copy in each. Property names, types and nesting must match exactly, and the JSON in pushes is decoded using these property names.

Step 4: How do you build the Lock Screen and Dynamic Island UI?

Replace MyAppWidget/MyAppWidgetLiveActivity.swift with the code below.

  1import ActivityKit
  2import WidgetKit
  3import SwiftUI
  4
  5/// The Live Activity's data. The app needs an identical copy
  6struct DeliveryAttributes: ActivityAttributes {
  7    /// Dynamic data, replaced by every update or push
  8    public struct ContentState: Codable, Hashable {
  9        var status: String
 10        var progress: Double
 11        var eta: Date
 12    }
 13
 14    /// Static data, set at start and never changed
 15    var orderNumber: String
 16    var shopName: String
 17}
 18
 19struct MyAppWidgetLiveActivity: Widget {
 20    var body: some WidgetConfiguration {
 21        ActivityConfiguration(for: DeliveryAttributes.self) { context in
 22            // Lock Screen, and the banner on models without a Dynamic Island
 23            DeliveryLockScreenView(context: context)
 24                .activityBackgroundTint(Color.orange.opacity(0.15))
 25                .activitySystemActionForegroundColor(.orange)
 26        } dynamicIsland: { context in
 27            DynamicIsland {
 28                // Expanded: shown on long press, or when an update with an alert arrives
 29                DynamicIslandExpandedRegion(.leading) {
 30                    Label(context.attributes.shopName, systemImage: "bag.fill")
 31                        .font(.caption)
 32                        .foregroundStyle(.orange)
 33                }
 34                DynamicIslandExpandedRegion(.trailing) {
 35                    CountdownText(eta: context.state.eta)
 36                        .font(.caption.bold())
 37                }
 38                DynamicIslandExpandedRegion(.center) {
 39                    Text(context.state.status)
 40                        .font(.headline)
 41                        .lineLimit(1)
 42                }
 43                DynamicIslandExpandedRegion(.bottom) {
 44                    ProgressView(value: context.state.progress)
 45                        .tint(.orange)
 46                }
 47            } compactLeading: {
 48                Image(systemName: "bag.fill")
 49                    .foregroundStyle(.orange)
 50            } compactTrailing: {
 51                CountdownText(eta: context.state.eta)
 52                    .frame(width: 44)
 53                    .font(.caption2.bold())
 54            } minimal: {
 55                Image(systemName: "bag.fill")
 56                    .foregroundStyle(.orange)
 57            }
 58            .keylineTint(.orange)
 59        }
 60    }
 61}
 62
 63struct DeliveryLockScreenView: View {
 64    let context: ActivityViewContext<DeliveryAttributes>
 65
 66    var body: some View {
 67        VStack(alignment: .leading, spacing: 8) {
 68            HStack {
 69                Label(context.attributes.shopName, systemImage: "bag.fill")
 70                    .font(.subheadline.bold())
 71                    .foregroundStyle(.orange)
 72                Spacer()
 73                Text("Order \(context.attributes.orderNumber)")
 74                    .font(.caption)
 75                    .foregroundStyle(.secondary)
 76            }
 77            HStack(alignment: .firstTextBaseline) {
 78                Text(context.isStale ? "Status may be out of date" : context.state.status)
 79                    .font(.headline)
 80                Spacer()
 81                CountdownText(eta: context.state.eta)
 82                    .font(.headline)
 83            }
 84            ProgressView(value: context.state.progress)
 85                .tint(.orange)
 86        }
 87        .padding()
 88    }
 89}
 90
 91/// The system runs the countdown, so you don't need to update the activity every second
 92struct CountdownText: View {
 93    let eta: Date
 94
 95    var body: some View {
 96        if eta > Date() {
 97            Text(timerInterval: Date()...eta, countsDown: true)
 98                .monospacedDigit()
 99                .multilineTextAlignment(.trailing)
100        } else {
101            Text("Arriving now")
102        }
103    }
104}

The first closure of ActivityConfiguration is the Lock Screen view, and the dynamicIsland closure holds the three Dynamic Island presentations. Provide all three. The system picks one for each situation.

Presentation When it appears Where in the code
Compact When only one Live Activity is running, one piece on each side of the camera compactLeading, compactTrailing
Minimal When several apps have Live Activities at once, shrunk to a small circle minimal
Expanded On long press, or briefly when an update with an alert arrives The regions inside DynamicIsland { }

The expanded presentation places content in regions with DynamicIslandExpandedRegion.

Region Position
.leading Left of the camera, wraps downward when there’s more content
.trailing Right of the camera, wraps downward when there’s more content
.center Below the camera
.bottom Below the other three, a good place for a progress bar

Keep these points in mind when you build the UI.

  • Let the system run countdowns. Text(timerInterval:countsDown:) ticks by itself, so don’t update the activity often just to change the seconds.
  • Keep compact views narrow. Content gets truncated when space is tight, so use an icon or very short text.
  • Lock Screen height. The system may truncate views taller than 160 points.
  • Stale state. Pass a staleDate when you start or update. After that time context.isStale becomes true, and the UI can tell the user the information may be out of date.
  • Animation. Animation modifiers such as withAnimation are ignored in Live Activities. The system uses its own transitions.
  • Images. An image can’t be larger than the area it’s shown in. Oversized images can make the activity fail to start.

Tapping a Live Activity opens the app by default.

Step 5: How do you start, update and end it from the app?

Replace the app’s MyApp/ContentView.swift with the code below. It starts with the same DeliveryAttributes as the extension, followed by a class that manages the Live Activity and three buttons.

  1import ActivityKit
  2import SwiftUI
  3
  4/// Keep this identical to the definition in the widget
  5struct DeliveryAttributes: ActivityAttributes {
  6    public struct ContentState: Codable, Hashable {
  7        var status: String
  8        var progress: Double
  9        var eta: Date
 10    }
 11
 12    var orderNumber: String
 13    var shopName: String
 14}
 15
 16@available(iOS 16.2, *)
 17@MainActor
 18final class DeliveryActivityManager: ObservableObject {
 19    @Published var message = ""
 20    private var activity: Activity<DeliveryAttributes>?
 21
 22    init() {
 23        // Pick up a running activity after the system closed and relaunched the app
 24        activity = Activity<DeliveryAttributes>.activities.first
 25    }
 26
 27    func start() {
 28        guard ActivityAuthorizationInfo().areActivitiesEnabled else {
 29            message = "Live Activities are turned off in Settings"
 30            return
 31        }
 32        let attributes = DeliveryAttributes(orderNumber: "A1024", shopName: "Corner Café")
 33        let state = DeliveryAttributes.ContentState(
 34            status: "Order accepted",
 35            progress: 0.2,
 36            eta: Date().addingTimeInterval(20 * 60)
 37        )
 38        do {
 39            let activity = try Activity.request(
 40                attributes: attributes,
 41                content: ActivityContent(state: state, staleDate: Date().addingTimeInterval(30 * 60)),
 42                pushType: .token // Pass nil if you don't update with pushes
 43            )
 44            self.activity = activity
 45            message = "Started \(activity.id)"
 46            watchPushToken(of: activity)
 47        } catch {
 48            message = "Couldn't start: \(error.localizedDescription)"
 49        }
 50    }
 51
 52    func update() async {
 53        guard let activity else { return }
 54        let state = DeliveryAttributes.ContentState(
 55            status: "Order picked up",
 56            progress: 0.7,
 57            eta: Date().addingTimeInterval(8 * 60)
 58        )
 59        await activity.update(
 60            ActivityContent(state: state, staleDate: Date().addingTimeInterval(15 * 60)),
 61            alertConfiguration: AlertConfiguration(title: "Order picked up", body: "Arriving in about 8 minutes", sound: .default)
 62        )
 63        message = "Updated"
 64    }
 65
 66    func end() async {
 67        guard let activity else { return }
 68        let final = DeliveryAttributes.ContentState(status: "Delivered", progress: 1, eta: Date())
 69        await activity.end(ActivityContent(state: final, staleDate: nil), dismissalPolicy: .default)
 70        self.activity = nil
 71        message = "Ended"
 72    }
 73
 74    private func watchPushToken(of activity: Activity<DeliveryAttributes>) {
 75        Task {
 76            // The token arrives asynchronously and can change, so report it every time
 77            for await data in activity.pushTokenUpdates {
 78                let token = data.map { String(format: "%02x", $0) }.joined()
 79                print("Live Activity push token: \(token)")
 80                // Send the token and the order number to your server here
 81            }
 82        }
 83    }
 84}
 85
 86@available(iOS 16.2, *)
 87struct DeliveryPanel: View {
 88    @StateObject private var manager = DeliveryActivityManager()
 89
 90    var body: some View {
 91        VStack(spacing: 16) {
 92            Button("Start delivery") { manager.start() }
 93                .buttonStyle(.borderedProminent)
 94            Button("Order picked up") { Task { await manager.update() } }
 95            Button("Delivered, end") { Task { await manager.end() } }
 96            Text(manager.message)
 97                .font(.footnote)
 98                .foregroundStyle(.secondary)
 99        }
100    }
101}
102
103struct ContentView: View {
104    var body: some View {
105        if #available(iOS 16.2, *) {
106            DeliveryPanel()
107                .padding()
108        } else {
109            Text("Live Activities require iOS 16.2 or later")
110        }
111    }
112}

Each of the three actions maps to one ActivityKit method.

Action Method Key points
Start Activity.request(attributes:content:pushType:) Call it while the app is in the foreground. pushType: .token means you’ll update with pushes and get a push token
Update activity.update(_:alertConfiguration:) Pass a new ContentState. With an AlertConfiguration, models with a Dynamic Island expand briefly and other models show a banner
End activity.end(_:dismissalPolicy:) Pass the final state. .default keeps it on the Lock Screen for a while, .immediate removes it right away, .after(date) removes it at a set time

Starting can fail, for example when the user turned off Live Activities or the device already has the maximum number of activities running. Put request in do/catch and show the error to the user. Activity<DeliveryAttributes>.activities returns all of this app’s running activities, so use it to pick up earlier activities after the app restarts.

If you’re not using pushes yet, change pushType: .token to pushType: nil. You can also skip the push capability below for now.

Step 6: Which App IDs and provisioning profiles do you need?

The extension that hosts the Live Activity is a separate executable and is signed with its own provisioning profile. Sign in to developer.apple.com, open Certificates, Identifiers & Profiles and work through these steps in order.

  1. Register an App ID for the extension. On the Identifiers page, click “+”, choose App IDs > App, select Explicit for the Bundle ID and enter com.example.myapp.myappwidget.
  2. Enable Push Notifications on the app’s App ID. You only need this for push updates. In the Capabilities of the app’s App ID (com.example.myapp), check Push Notifications and save.
  3. Generate provisioning profiles. Create a Development profile for the extension’s App ID. After enabling push for the app, open the app’s existing profile, click Edit, then Save without changes, and download it again.
  4. Put them in the keychain folder. Put both profiles in KXBuild’s keychain folder and delete the app’s old profile. At build time KXBuild matches a profile to the app and to the extension by Bundle ID.

Push also needs the aps-environment entitlement on the app, set up exactly as for regular push notifications. Follow the “Add entitlements to the project” section of iOS push notifications with APNs, from p8 key to your first push. ActivityKit provides the Live Activity push token, so the app doesn’t need to call registerForRemoteNotifications to get it.

For the full rules on signing extensions, see Sign iOS app extensions: certificates and profiles for share and notification service extensions.

Step 7: Verify on your iPhone

  1. Select your iPhone in the Status Bar and press F5. KXBuild builds, signs and installs the app and the extension together, then launches the app.
  2. Tap “Start delivery”. Pull down Notification Center or lock the phone to see the Lock Screen view. On a model with a Dynamic Island, go to the Home Screen and an icon and a countdown appear on both sides of the island.
  3. Long-press the Dynamic Island to see the expanded presentation.
  4. Go back to the app and tap “Order picked up”. The Dynamic Island expands briefly, and the status and progress on the Lock Screen change.
  5. Tap “Delivered, end”. The activity disappears from the Dynamic Island, and the Lock Screen keeps the final state.

Keep these points in mind while debugging.

  • F5 debugs the app. The Live Activity UI is rendered in the extension process, so breakpoints in the app don’t reach it. Keep the UI for display only, and compute the state in the app or on the server.
  • Find the push token. With pushType: .token, after the activity starts, Output > KXBuild Console in VS Code prints Live Activity push token: followed by a hex string. You need it in the next section.
  • Nothing happens? Check the setting first. Find the app in Settings and make sure Live Activities is on.

How do you update a Live Activity with APNs pushes?

When the app isn’t in the foreground, the most reliable way to update an activity is a push from your server. Here’s the flow.

  1. The app starts the activity with pushType: .token, gets this activity’s push token from pushTokenUpdates and reports it to your server.
  2. When the order status changes, the server uses the token to send a liveactivity push through APNs.
  3. The system wakes the extension and renders the UI again from the content-state in the push.

The push token belongs to one activity and is different from the device token used for regular pushes. Report a new token every time you start a new activity. The token can also change while the activity is running, so the server must replace the old one when a new token arrives.

Request headers

The request uses the same APNs endpoint and the same .p8-signed JWT as regular pushes. Only three headers differ.

Header Value Notes
apns-push-type liveactivity Fixed value
apns-topic com.example.myapp.push-type.liveactivity The app’s Bundle ID plus .push-type.liveactivity, not the extension’s Bundle ID
apns-priority 5 or 10 10 delivers immediately and counts against the hourly budget. 5 doesn’t count against it

Pick the endpoint for the environment. Debug builds installed with F5 use https://api.sandbox.push.apple.com, and TestFlight and App Store builds use https://api.push.apple.com. To create a .p8 key, see Create an APNs authentication key in the push tutorial.

Request body

 1{
 2  "aps": {
 3    "timestamp": 1792370815,
 4    "event": "update",
 5    "content-state": {
 6      "status": "Order picked up",
 7      "progress": 0.7,
 8      "eta": 814064095
 9    },
10    "stale-date": 1792371715
11  }
12}
Field Purpose
timestamp The current time in seconds since 1970. The system uses it to show only the latest update
event update to update, end to end
content-state Fields must match ContentState exactly. The system decodes it with the default rules, so custom JSON coding strategies aren’t supported
stale-date Optional. After this time the UI shows the stale state
dismissal-date Optional, only for end. The activity is removed from the Lock Screen at this time. A time in the past removes it immediately
alert Optional. With it, models with a Dynamic Island expand and other models show a banner

eta is a Date, and it’s the easiest field to get wrong. The system decodes Date with the default rules, which expect seconds since January 1, 2001, not a Unix timestamp. On the server, subtract 978307200 from the Unix seconds. If you’d rather avoid the offset, change the field to a Double of Unix seconds and convert it in the UI with Date(timeIntervalSince1970:).

Send a test push from your computer

In the folder you used in the push tutorial (it already holds the .p8 key), create la.mjs and change the constants at the top. The script uses only built-in Node.js modules and fills in timestamp and the converted eta for you.

 1import crypto from 'node:crypto'
 2import fs from 'node:fs'
 3import http2 from 'node:http2'
 4
 5const TEAM_ID = 'ABCDE12345'                      // Team ID
 6const KEY_ID = 'XYZ1234567'                       // Key ID
 7const KEY_FILE = 'AuthKey_XYZ1234567.p8'          // .p8 file
 8const BUNDLE_ID = 'com.example.myapp'             // The app's Bundle ID, not the extension's
 9const HOST = 'https://api.sandbox.push.apple.com' // Debug builds installed with F5 use the development environment
10
11function makeJwt() {
12  const b64 = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url')
13  const unsigned = b64({ alg: 'ES256', kid: KEY_ID }) + '.' + b64({ iss: TEAM_ID, iat: Math.floor(Date.now() / 1000) })
14  const key = crypto.createPrivateKey(fs.readFileSync(KEY_FILE))
15  const sig = crypto.sign('sha256', Buffer.from(unsigned), { key, dsaEncoding: 'ieee-p1363' })
16  return unsigned + '.' + sig.toString('base64url')
17}
18
19const [event, token, status = 'Order picked up', minutes = '8', progress = '0.7'] = process.argv.slice(2)
20if (!['update', 'end'].includes(event) || !token) {
21  console.log('Usage: node la.mjs update|end <push token> [status] [minutes left] [progress]')
22  process.exit(1)
23}
24
25const now = Math.floor(Date.now() / 1000)
26const aps = {
27  timestamp: now,
28  event,
29  'content-state': {
30    status,
31    progress: Number(progress),
32    // Swift decodes Date as seconds since 2001-01-01 by default
33    eta: now + Number(minutes) * 60 - 978307200,
34  },
35}
36if (event === 'update') aps['stale-date'] = now + 15 * 60
37if (event === 'end') aps['dismissal-date'] = now + 60 * 60
38const payload = JSON.stringify({ aps })
39console.log(payload)
40
41const client = http2.connect(HOST)
42client.on('error', (err) => { console.error(err.message); process.exit(1) })
43const req = client.request({
44  ':method': 'POST',
45  ':path': '/3/device/' + token,
46  authorization: 'bearer ' + makeJwt(),
47  'apns-topic': BUNDLE_ID + '.push-type.liveactivity',
48  'apns-push-type': 'liveactivity',
49  'apns-priority': '10',
50})
51let code
52let body = ''
53req.on('response', (headers) => { code = headers[':status'] })
54req.setEncoding('utf8')
55req.on('data', (chunk) => { body += chunk })
56req.on('end', () => { console.log(code, body); client.close() })
57req.end(payload)

In the app, tap “Start delivery”, copy the push token from the KXBuild Console, lock the phone and run the command below.

1node la.mjs update PUSH_TOKEN "Courier is 1 km away" 3 0.9

A 200 response means APNs accepted the push, and the status, countdown and progress on the Lock Screen change within a few seconds. Run end to end the activity.

1node la.mjs end PUSH_TOKEN "Delivered" 0 1

For 400 or 403 responses, the reason in the response explains why. Check it against the APNs error table in the push tutorial. The most common mistakes are setting apns-topic to the extension’s Bundle ID and sending a Debug build’s token to the production endpoint.

Update frequency

The system gives each app an hourly budget for Live Activity pushes. Apple doesn’t publish the exact number. Pushes with apns-priority: 10 count against the budget and get throttled when you exceed it. Priority 5 doesn’t count, but delivery is less immediate.

  • Use 5 for changes the user doesn’t need to see right away (progress going from 60% to 65%), and 10 for key moments (the courier is at the door).
  • Let the system run countdowns with Text(timerInterval:). Don’t push every second.
  • For cases that update several times a minute, such as live scores, add NSSupportsLiveActivitiesFrequentUpdates set to YES in the app’s Info.plist to ask for a higher rate. Users can turn it off in Settings, so check ActivityAuthorizationInfo().frequentPushesEnabled in the app.

A push may not arrive when the phone is offline, and pushes that arrive after the activity ended are ignored. Call update again when the app returns to the foreground so it never shows stale information.

Start an activity with a push

On iOS 17.1 and earlier, pushes can only update and end Live Activities, not start them. On later versions, the app can get a start token from Activity<DeliveryAttributes>.pushToStartTokenUpdates, and the server uses it to send a push with event set to start. The body must include attributes-type (here DeliveryAttributes), attributes and alert. Add this once the basic flow works.

Why doesn’t a Live Activity show up, and how do you fix it?

Symptom Cause Fix
Activity.request throws an unsupported or unauthorized error The app’s Info.plist lacks NSSupportsLiveActivities, or the user turned off Live Activities Check the build setting as described above, and turn on Live Activities for the app in Settings
Starts fine but nothing shows on the Lock Screen or in the Dynamic Island The extension wasn’t installed, or the phone runs a version older than the iOS 17 the extension requires Make sure the extension was signed too when you pressed F5, and that the phone runs iOS 17 or later
Only the Lock Screen view, no Dynamic Island The model has no Dynamic Island Expected. Test on a model with a Dynamic Island
Fails to start with a limit reached error Too many activities are running at once End older activities, and handle errors from request
Never gets a push token The app doesn’t have the push capability or lacks aps-environment Enable Push Notifications, regenerate the profiles and add the entitlement
Push returns 200 but the UI doesn’t change content-state field names or types don’t match ContentState, or Date was sent as Unix seconds Check each field, and send Date as seconds since 2001
Push returns 400 TopicDisallowed or DeviceTokenNotForTopic apns-topic is wrong, or the token doesn’t match the environment Use the app’s Bundle ID plus .push-type.liveactivity as the topic, and the sandbox endpoint for Debug builds
The build says the extension’s provisioning profile is missing There’s no profile for com.example.myapp.myappwidget in keychain Register the App ID, generate a profile and put it in keychain
The activity disappears after several hours It hit the 8-hour limit Expected. Split long tasks into several activities, or use push notifications instead

For signing and installation errors, see 16 common iOS code signing and .ipa install errors, and how to fix them.

How do you ship an app with Live Activities?

For release, both the app and the extension need App Store provisioning profiles. Put them in the keychain folder and run KXBuild: Build for App Store (.ipa) from the Command Palette or the project’s context menu, or run the command below.

1kxbuild build .\MyApp.xcodeproj -c Release --export app-store

The resulting build\MyApp.ipa already includes the iOS Live Activities extension. Production pushes go to https://api.push.apple.com, with a JWT signed by a .p8 key whose environment is Production, and the token must be the new one reported by the production build. The extension and the app must have the same version number. When you change it, update MyApp and MyAppWidget together in the Project Settings Editor.

FAQ

What’s the minimum iOS version for Live Activities?

ActivityKit is available from iOS 16.1, and the ActivityContent API used here needs iOS 16.2. The extension added by KXBuild requires iOS 17 or later, so a Live Activity built with this tutorial shows on iPhones running iOS 17 or later.

How long can a Live Activity stay on screen?

A Live Activity can run for up to 8 hours, then the system ends it and removes it from the Dynamic Island. After it ends it can stay on the Lock Screen for up to 4 more hours, so 12 hours at most on the Lock Screen.

Do Live Activities work on iPhones without a Dynamic Island?

Yes. Models without a Dynamic Island show the Lock Screen view, and updates with an alert appear as a banner when the phone is unlocked. The Dynamic Island presentations appear only on iPhone 14 Pro and later models that have one.

What apns-topic do Live Activity pushes use?

Use the app’s Bundle ID plus .push-type.liveactivity, for example com.example.myapp.push-type.liveactivity, and set apns-push-type to liveactivity. Don’t use the extension’s Bundle ID, and send to the activity token from pushTokenUpdates.

Why does a push return 200 but the Live Activity doesn’t update?

Usually the field names or types in content-state don’t match ContentState, so decoding fails and nothing updates. Send Date fields as seconds since January 1, 2001, set timestamp to the current time, and remember that pushes to an activity that already ended are ignored.