Server side Live Activities guide

September 23, 2024

A Live Activity on an iOS Lock Screen with an emoji and the text 'I sent this from a server woooooo'.

iOS 17.2 gained the capability to start Live Activities from a server, which is pretty cool and handy! I’ve been playing around with it a bit and found some parts a bit confusing, so I thought I’d do a little write up for future me as well as anyone else who could benefit!

(For the uninitiated, Live Activities are the cool iOS feature that adds a live view of a specific activity to your Dynamic Island (if available) and Lock Screen, for example to see how your Uber Eats order is coming along.)

Overview of starting a Live Activity server-side

It’s pretty straightforward, just a few steps:

Get token

In your AppDelegate’s didFinishLaunching or somewhere very early in the lifecycle, start an async sequence to listen for a pushToStartToken so you can get the token:

Task {
    for await pushToken in Activity<IceCreamWidgetAttributes>.pushToStartTokenUpdates {
        let pushTokenString = pushToken.reduce("") { $0 + String(format: "%02x", $1) }
        print("Our push token is: \(pushTokenString)")
    }
}

Use token to start Live Activity server-side

Now that you have the token, we use that to send a push notification (using APNs) to start the Live Activity on the user’s device. There’s lots of server side libraries for this, or you can just use curl, or you can an online site to test like this one (in the case of the latter where you’re uploading your token to random sites, please create a sample token that you delete afterward).

The key points that differ from sending a “normal” push notification:

Headers

  • Set your push token to the pushToStartToken you received above
  • Set the topic to your app’s normal bundle ID with an added suffix of .push-type.liveactivity
  • Set the priority to 5 for low priority or 10 for a high priority notification (does not seem like any values in between work)
  • Set apns-push-type to liveactivity

Payload

  • timestamp field with the current unix timestamp
  • event field set to start
  • attributes-type set to the name of your Swift Live Activities attributes struct
  • attributes with a dictionary representing your Swift Live Activity attributes
  • content-state with the initial content state as a dictionary, similar to attributes
  • alert field set with a title and a body (will silently fail if you just set alert to a string like the old days)

Note that you cannot use the old certificate based authentication and instead have to use token based authentication and http2.

Send the http request and it should start the Live Activity on the iOS device! 🎉

Aside

Sending push notifications is kinda complicated, so you likely want a server-side library. I wanted to play around with Node for this for the first time, and in case you go down that path, in September 2024 Node is in a weirdly lacking spot for APNs libraries. The de facto one is abandoned, the community replacement for it doesn’t work with TypeScript, and there’s a third option with TypeScript support but it isn’t super popular and has some issues. I ended up going back to Go, and there’s an excellent APNs library there.

It’s broken on iOS 17

On iOS 17, getting the above push token is really hard (you can seemingly only get it once). I tried for ages to get the token before stumbling upon a thread on the Apple forums where a user said to delete the app, reboot your device, then fresh install it. Sure enough that worked and the token displayed, but if I just rebooted the device, or just reinstalled the app, it wouldn’t. Had to do all of them. And no it didn’t change if I used a release configuration.

I tried this on iOS 17.6.1 (latest iOS 17 release at the time of writing). It does not seem to be an issue at all on iOS 18.

The difficulty in acquiring it makes it incredibly hard to use on iOS 17 if you add in the feature in an update and the user isn’t getting your app from a fresh install, to the extent that I can’t really trust its reliability on iOS 17 as a feature you could advertise, for instance.

John Gruber recently wrote about the Apple Sports app and wondered why its Live Activities feature is iOS 18 only. A reader wrote in to mention the new broadcast push notifications feature requiring iOS 18, and that well may be it, but I’d say it’s equally as likely that it just doesn’t work reliably enough on iOS 17 for even Apple to bother.

Update/end the activity

This part admittedly confused me a bit. The docs state:

  • Send the push-to-start token to your server and use the pushToStartTokenUpdates sequence to receive token updates. Similar to the update token, update it on your server when needed and invalidate the old token.
  • While the system starts the new Live Activity and wakes up your app, you receive the push token you use for updates. To update and end the Live Activity on devices that aren’t running iOS 18 or iPadOS 18, use this update push token as if you obtained it by starting a Live Activity from within your app.

I assumed that this all operated through that same pushToStartTokenUpdates, because as soon as you start the activity server-side, your app wakes up, and your pushToStartTokenUpdates async sequence fires again with a “new” token.

However the “new” token is just the same one that you started the activity with, and if you try to end your activity server-side with this token, nothing happens.

Turns out, your pushToStartTokenUpdates is (per the name!) only able to start Live Activities. Not sure why it fires a second time with the same token, but you do want to use that async sequence to monitor for changes to the start token, because it might change and the next time you want to start a new Live Activity you’ll need that token.

To update/end your Live Activity, what you actually want to do is create a separate async sequence to monitor your app for Live Activities that get created, and then monitor its push token:

// Listen for local Live Activity updates
for await activity in Activity<IceCreamWidgetAttributes>.activityUpdates {
    // Upon finding one, listen for its push token (it is not available immediately!)
    Task {
        for await pushToken in activity.pushTokenUpdates {
            let pushTokenString = pushToken.reduce("") { $0 + String(format: "%02x", $1) }
            print("New activity detected with push token: \(pushTokenString)")
        }
    }
}

iOS will also call this async sequence on start of a new Live Activity from a server, and you use that token to update/end it.

I’m not blaming the documentation on this, I understand it as it is written now, but I wanted to clarify in case anyone else gets confused.

Once your server is made aware of this token, you can end your Live Activity server-side with the following changes from the above start considerations:

  • Payload: event should be end or update
  • Payload: If ending you might want a dismissal-date unix timestamp. If you don’t set this, iOS will immediately remove the Live Activity from the Dynamic Island but leave it on the Lock Screen for up to four hours. You may want this, but you can control how long it stays there by setting dismissal-date, if you set it to now or in the past it will remove it upon receipt of the notification.
  • Payload: Send a new content-state if updating (optional if ending, if ending and you want to leave it on the lock screen (see previous point) you can set a content-state which will serve as the final content state for the Live Activity)
  • Payload: Do not send attributes or attributes-type as these are intended to be immutable through the life of the Live Activity

There’s other interesting ones that you might want to consider but aren’t as important, like stale-date, discussed in the docs.

That’s it!

That should cover most things! I want to thank the incredible Francesc Bruguera for helping me get unstuck a few places.