Push Notifications with APNSwift
In the previous post, we added authentication to the Landmarks app. The server now knows who each user is. That opens the door to something users actually expect from a modern app: push notifications.
This post is a bit personal for me because we'll be using APNSwift, a library I created and maintain. It's the HTTP/2 Apple Push Notification client for Swift on the server, and it's used by Apple's own teams internally. So I have some opinions on how to set it up correctly.
The companion repo has the complete working code: View Code
How Push Notifications Work
Before writing any code, here's the flow:
- The iOS app requests notification permission from the user
- If granted, iOS gives the app a device token (a unique identifier for that device)
- The app sends that token to your server, associated with the authenticated user
- When something happens (a landmark is updated, a reservation is confirmed), the server sends a notification to Apple's push service (APNs)
- APNs delivers it to the device
The server never talks to the device directly. It always goes through Apple.
Setting Up APNSwift on the Server
Add the dependency to your Package.swift:
.package(url: "https://github.com/swift-server/apnswift.git", from: "5.0.0"),
And add it to your target:
.product(name: "APNSCore", package: "apnswift"),
.product(name: "APNS", package: "apnswift"),
Authentication with Apple
APNSwift supports two authentication methods: JWT (token-based) and certificate-based. JWT is simpler for server-to-server communication and doesn't expire the way certificates do. You'll need three things from your Apple Developer account:
- A Key ID from the Keys section of the developer portal
- A Team ID from your membership page
- A private key file (.p8) downloaded when you create the key
Configure the APNs client in your Vapor app:
import APNS
import APNSCore
func configureAPNS(_ app: Application) async throws {
let key = try APNSConfiguration.SigningMode.PrivateKey(
filePath: Environment.get("APNS_KEY_PATH") ?? "AuthKey.p8"
)
let apnsConfig = APNSClientConfiguration(
authenticationMethod: .jwt(
privateKey: key,
keyIdentifier: Environment.get("APNS_KEY_ID") ?? "",
teamIdentifier: Environment.get("APNS_TEAM_ID") ?? ""
),
environment: app.environment == .production ? .production : .sandbox
)
app.apns.containers.use(
apnsConfig,
eventLoopGroupProvider: .shared(app.eventLoopGroup),
responseDecoder: JSONDecoder(),
requestEncoder: JSONEncoder(),
as: .default
)
}
Notice the environment switch. During development, you send to Apple's sandbox. In production, you send to the production APNs endpoint. Getting this wrong is the most common reason notifications "don't work."
Storing Device Tokens
When a device registers for notifications, we need to store its token and associate it with a user. Here's the Fluent model:
final class DeviceToken: Model, Content, @unchecked Sendable {
static let schema = "device_tokens"
@ID(key: .id) var id: UUID?
@Parent(key: "user_id") var user: User
@Field(key: "token") var token: String
@Field(key: "platform") var platform: String
init() {}
init(id: UUID? = nil, userID: UUID, token: String, platform: String = "ios") {
self.id = id
self.$user.id = userID
self.token = token
self.platform = platform
}
}
The migration:
struct CreateDeviceToken: AsyncMigration {
func prepare(on database: Database) async throws {
try await database.schema("device_tokens")
.id()
.field("user_id", .uuid, .required, .references("users", "id"))
.field("token", .string, .required)
.field("platform", .string, .required)
.unique(on: "token")
.create()
}
func revert(on database: Database) async throws {
try await database.schema("device_tokens").delete()
}
}
The unique constraint on token is important. If a user reinstalls the app, the device might get a new token. If they re-register, we want to update rather than duplicate.
Registration Endpoint
This endpoint sits behind the JWT auth middleware we built in Post 1:
protected.post("devices", "register") { req async throws -> HTTPStatus in
let user = try req.auth.require(AuthenticatedUser.self)
let input = try req.content.decode(DeviceRegistrationInput.self)
// Upsert: update if token exists, create if it doesn't
if let existing = try await DeviceToken.query(on: req.db)
.filter(\.$token == input.token)
.first()
{
existing.$user.id = user.id
try await existing.save(on: req.db)
} else {
let device = DeviceToken(userID: user.id, token: input.token)
try await device.save(on: req.db)
}
return .ok
}
struct DeviceRegistrationInput: Content {
let token: String
}
Sending Notifications
Now the fun part. When something happens that a user should know about, we send a push. Here's a service that sends a notification to all of a user's devices:
import APNS
import APNSCore
struct PushService {
let app: Application
func sendToUser(
_ userID: UUID,
title: String,
body: String,
data: [String: String] = [:],
on db: Database
) async throws {
let tokens = try await DeviceToken.query(on: db)
.filter(\.$user.$id == userID)
.all()
for device in tokens {
let alert = APNSAlertNotification(
alert: .init(title: .raw(title), body: .raw(body)),
expiration: .immediately,
priority: .immediately,
topic: Environment.get("APNS_TOPIC") ?? "com.kylebrowning.landmarks"
)
do {
try await app.apns.client(.default).sendAlertNotification(
alert,
deviceToken: device.token
)
} catch let error as APNSError {
// If the token is invalid, clean it up
if error.reason == .badDeviceToken || error.reason == .unregistered {
try await device.delete(on: db)
}
}
}
}
}
Two things to notice here. First, the topic must match your app's bundle identifier. Second, we handle invalid tokens by deleting them. Apple will tell you when a token is no longer valid (the user uninstalled the app, for example), and you should respect that by cleaning up.
Triggering from Business Logic
Wire the push service into your existing routes. For example, when a reservation is confirmed:
protected.post("reservations") { req async throws -> ReservationResponse in
let user = try req.auth.require(AuthenticatedUser.self)
let input = try req.content.decode(ReservationInput.self)
let reservation = Reservation(
userID: user.id,
landmarkID: input.landmarkID,
date: input.date
)
try await reservation.save(on: req.db)
// Load the landmark name for a nice notification
let landmark = try await Landmark.find(input.landmarkID, on: req.db)
let push = PushService(app: req.application)
try await push.sendToUser(
user.id,
title: "Reservation Confirmed",
body: "Your visit to \(landmark?.name ?? "the landmark") is booked for \(input.date).",
on: req.db
)
return ReservationResponse(from: reservation)
}
The Client Side
Requesting Permission
On the iOS side, we need to ask the user for permission and then register for remote notifications. This happens early in the app lifecycle:
import UserNotifications
struct NotificationService: Sendable {
var requestPermission: @Sendable () async throws -> Bool
var register: @Sendable (_ token: String) async throws -> Void
}
extension NotificationService {
static func live(authService: AuthService) -> NotificationService {
NotificationService(
requestPermission: {
let center = UNUserNotificationCenter.current()
let granted = try await center.requestAuthorization(options: [.alert, .badge, .sound])
if granted {
await MainActor.run {
UIApplication.shared.registerForRemoteNotifications()
}
}
return granted
},
register: { token in
guard let authToken = authService.loadToken() else { return }
let body = try JSONEncoder().encode(["token": token])
var request = URLRequest(url: URL(string: "\(API.baseURL)/devices/register")!)
request.httpMethod = "POST"
request.httpBody = body
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
let (_, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw NotificationError.registrationFailed
}
}
)
}
static let unimplemented = NotificationService(
requestPermission: { fatalError("NotificationService.requestPermission not implemented") },
register: { _ in fatalError("NotificationService.register not implemented") }
)
}
Register it in the environment:
extension EnvironmentValues {
@Entry var notificationService: NotificationService = .unimplemented
}
Handling the Device Token
You need an AppDelegate to receive the device token callback from iOS. SwiftUI apps can use @UIApplicationDelegateAdaptor:
class AppDelegate: NSObject, UIApplicationDelegate {
var notificationService: NotificationService?
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
Task {
try? await notificationService?.register(token)
}
}
func application(
_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error
) {
print("Failed to register for notifications: \(error)")
}
}
In your App struct:
@main
struct LandmarksApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup {
ContentView(authService: .live)
.environment(\.notificationService, .live(authService: .live))
.onAppear {
appDelegate.notificationService = .live(authService: .live)
}
}
}
}
Requesting Permission After Login
Don't ask for notification permission on first launch - that's a terrible user experience. Ask after the user has signed in and seen the value of the app. Add it to the post-login flow:
struct LandmarksNavigationView: View {
@Environment(\.notificationService) private var notificationService
@State private var hasRequestedNotifications = false
var body: some View {
NavigationStack {
LandmarkListView()
}
.task {
guard !hasRequestedNotifications else { return }
hasRequestedNotifications = true
_ = try? await notificationService.requestPermission()
}
}
}
Deep Linking with the Navigator
When a user taps a push notification, you want to take them directly to the relevant screen. In Series 1 Post 1, we built a Navigator with an enum-based destination system. Push notifications are the perfect use case for it.
First, include deep link data in the notification payload. Update the server-side PushService to include structured data:
func sendToUser(
_ userID: UUID,
title: String,
body: String,
deepLink: DeepLink? = nil,
on db: Database
) async throws {
let tokens = try await DeviceToken.query(on: db)
.filter(\.$user.$id == userID)
.all()
var userInfo: [String: String] = [:]
if let deepLink {
userInfo["deep_link_type"] = deepLink.type
userInfo["deep_link_id"] = deepLink.id
}
for device in tokens {
let alert = APNSAlertNotification(
alert: .init(title: .raw(title), body: .raw(body)),
expiration: .immediately,
priority: .immediately,
topic: Environment.get("APNS_TOPIC") ?? "com.kylebrowning.landmarks",
payload: userInfo
)
do {
try await app.apns.client(.default).sendAlertNotification(
alert,
deviceToken: device.token
)
} catch let error as APNSError {
if error.reason == .badDeviceToken || error.reason == .unregistered {
try await device.delete(on: db)
}
}
}
}
struct DeepLink {
let type: String // "landmark" or "reservation"
let id: String
}
When confirming a reservation, pass the deep link:
try await push.sendToUser(
user.id,
title: "Reservation Confirmed",
body: "Your visit to \(landmark?.name ?? "the landmark") is booked.",
deepLink: DeepLink(type: "landmark", id: "\(input.landmarkID)"),
on: req.db
)
On the client side, parse the notification payload and route it through the Navigator:
extension AppDelegate: UNUserNotificationCenterDelegate {
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification
) async -> UNNotificationPresentationOptions {
[.banner, .sound]
}
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse
) async {
let userInfo = response.notification.request.content.userInfo
guard let type = userInfo["deep_link_type"] as? String,
let id = userInfo["deep_link_id"] as? String else { return }
// Convert to a Navigator destination
if let destination = destination(from: type, id: id) {
await MainActor.run {
navigator?.navigate(to: destination)
}
}
}
private func destination(from type: String, id: String) -> AppDestination? {
switch type {
case "landmark":
guard let landmarkID = Int(id) else { return nil }
return .landmarkDetail(id: landmarkID)
case "reservation":
guard let reservationID = UUID(uuidString: id) else { return nil }
return .reservationDetail(id: reservationID)
default:
return nil
}
}
}
The navigator is the same Navigator from Post 1 of Series 1. Give the AppDelegate a reference to it:
class AppDelegate: NSObject, UIApplicationDelegate {
var notificationService: NotificationService?
var navigator: Navigator?
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
UNUserNotificationCenter.current().delegate = self
return true
}
}
And wire it up in your App struct:
@main
struct LandmarksApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@State private var navigator = Navigator()
var body: some Scene {
WindowGroup {
ContentView(authService: .live)
.environment(\.notificationService, .live(authService: .live))
.environment(navigator)
.onAppear {
appDelegate.notificationService = .live(authService: .live)
appDelegate.navigator = navigator
}
}
}
}
This is why the enum-based navigation pattern from Series 1 pays off. Adding deep linking is just a matter of mapping payload data to an existing AppDestination case. No new navigation infrastructure needed.
Handling Foreground Notifications
The willPresent delegate method above already handles this - returning [.banner, .sound] ensures notifications show even when the app is open. If you want to suppress banners for notifications about the screen the user is already viewing, check the Navigator's current destination:
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification
) async -> UNNotificationPresentationOptions {
let userInfo = notification.request.content.userInfo
// Don't show banner if user is already viewing this landmark
if let type = userInfo["deep_link_type"] as? String,
type == "landmark",
let id = userInfo["deep_link_id"] as? String,
let landmarkID = Int(id),
await navigator?.isViewing(.landmarkDetail(id: landmarkID)) == true {
return [.sound]
}
return [.banner, .sound]
}
Testing
The closure-based service makes testing notifications straightforward without actually hitting APNs:
@Test func registerSendsTokenToServer() async throws {
var registeredToken: String?
let service = NotificationService(
requestPermission: { true },
register: { token in registeredToken = token }
)
try await service.register("abc123def456")
#expect(registeredToken == "abc123def456")
}
On the server side, you can test the push service by mocking the APNs client or by verifying that the right device tokens are queried for a given user.
What's Next
We can now authenticate users and send them push notifications. But what happens when the user has no network connection? Right now the app just fails silently. In the next post, we'll add offline storage with SwiftData so the app works whether you have signal or not.