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:

  1. The iOS app requests notification permission from the user
  2. If granted, iOS gives the app a device token (a unique identifier for that device)
  3. The app sends that token to your server, associated with the authenticated user
  4. When something happens (a landmark is updated, a reservation is confirmed), the server sends a notification to Apple's push service (APNs)
  5. 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:

  1. A Key ID from the Keys section of the developer portal
  2. A Team ID from your membership page
  3. 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.