APNs Push Notifications

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Vapor 4.122.2 on Swift 6.4 (the package builds in the Swift 6 language mode with tools version 6.3, as the official template does; Vapor 4.122.2 itself needs tools version 6.0), with vapor/apns 5.1.0, APNSwift 7.1.0, Fluent 4.13.0 and fluent-sqlite-driver 4.9.0, checked on 2026-10-17. Every Swift listing on this page was compiled in the Swift 6 language mode with zero warnings in a scratch package, and the sending code was run against APNSwift’s own APNSTestServer mock, so the headers, payloads and outcomes shown are captured from real runs. Nothing was sent to Apple: there is no Apple Developer account, .p8 key or physical device behind this page, the mock speaks plain HTTP/1.1 instead of HTTPS with HTTP/2, and it does not check a key against Apple’s records. What the mock cannot prove (a key that Apple accepts, the real reasons APNs gives for a bad token, rate limits) is taken from Apple’s documentation and the package source and is marked as such. Vapor 5 is at 5.0.0-beta.4 and is not released yet; where its APNs package differs, that is noted. The page extends the Bookshelf service of the earlier pages of this section: when a loan is nearly due, the server pushes a reminder to the borrower’s iPhone (the mail counterpart is on the Sending Email page). The device side (asking permission, registering, the UNUserNotificationCenter delegate, extensions, Live Activities in ActivityKit) is covered in Notifications, APNs and Live Activities; this page is only the Vapor sender. Swift itself is covered by the Swift Reference, and Sendable by Actors, Isolation and Sendable.

How a push travels

A remote notification involves four parties, and the server owns only one of them:

  1. The app registers with APNs and receives a device token, which identifies this app on this device (and this environment).

  2. The app uploads the token to your server. That is the registration endpoint of this page.

  3. When something happens (a loan is nearly due), your server sends an HTTP/2 request to APNs, authenticated with a short-lived JWT signed by your .p8 key.

  4. APNs checks the request, stores or forwards it, and delivers it to the device, or answers with a status code that tells you what to do with the token.

sequenceDiagram autonumber participant App as Bookshelf iOS app participant V as Vapor server participant DB as Database participant A as APNs App->>V: PUT /v1/devices (token, environment) V->>DB: upsert push_tokens Note over V: a loan is nearly due V->>DB: tokens of the borrower V->>A: POST /3/device/TOKEN (JWT, apns-topic, payload) alt 200 A-->>V: apns-id else 410 Unregistered A-->>V: reason and timestamp V->>DB: delete the token else 429 or 5xx A-->>V: retry later end A->>App: banner, sound, badge

Vapor itself ships no APNs code. The vapor/apns package (VaporAPNS) is a thin integration: it adds app.apns and req.apns, keeps named containers of clients in app.storage and shuts them down with the application. Everything that talks to Apple, the JWT signing, the HTTP/2 client and the notification types, is APNSwift (products APNS and APNSCore), which also works without Vapor.

Packages and versions

// Package.swift (abridged)
dependencies: [
    .package(url: "https://github.com/vapor/vapor.git", from: "4.122.2"),
    .package(url: "https://github.com/vapor/apns.git", from: "5.1.0"),
    // Only needed because the code below names APNSClientConfiguration and APNSAlertNotification directly:
    .package(url: "https://github.com/kylebrowning/APNSwift.git", from: "7.1.0"),
],
targets: [
    .executableTarget(name: "App", dependencies: [
        .product(name: "Vapor", package: "vapor"),
        .product(name: "VaporAPNS", package: "apns"),
        .product(name: "APNS", package: "apnswift"),
        .product(name: "APNSCore", package: "apnswift"),
    ]),
    .testTarget(name: "AppTests", dependencies: [
        .target(name: "App"),
        .product(name: "VaporTesting", package: "vapor"),
        .product(name: "APNSTestServer", package: "apnswift"), // the mock APNs server used below
        .product(name: "Crypto", package: "swift-crypto"),     // to generate a throwaway key in tests
    ]),
]

vapor/apns 5.1.0 (2026-10-08) requires kylebrowning/APNSwift from 7.0.1 and Vapor from 4.110.1, and resolved APNSwift 7.1.0 here. The repository moved: older text links swift-server-community/APNSwift, which now redirects to kylebrowning/APNSwift. APNSwift 8.0.0-beta1 was published on 2026-10-16; this page does not use it. The vapor5 branch of vapor/apns builds against Vapor 5.0.0-beta.3 or newer.

What you need from Apple

Token-based authentication needs four values from the Apple Developer account. None of them is a secret except the key file:

Value Where it comes from Placeholder used on this page

Signing key (.p8)

Certificates, Identifiers and Profiles, Keys: create a key with the Apple Push Notifications service (APNs) capability. Apple lets you download the file once.

AuthKey_ABC123DEFG.p8, a PEM file starting with -----BEGIN PRIVATE KEY-----

Key ID

Ten characters, shown next to the key and embedded in its file name.

ABC123DEFG

Team ID

Ten characters, shown in the membership details.

DEF123GHIJ

Topic

The app’s bundle identifier. It becomes the apns-topic header.

com.example.bookshelf

One .p8 key signs for every app of the team, in both the development and the production environment, and it does not expire (it can be revoked). The older alternative, a per-app TLS certificate, expires yearly and is exposed as .tls(privateKey:certificateChain:); new work should use the key. The device token an app receives is specific to the environment: Xcode builds register a development token that only api.sandbox.push.apple.com accepts, while TestFlight and App Store builds register a production token for api.push.apple.com. Sending a token to the wrong one is the most common first failure (see Common errors).

Configuring the clients

APNSwift’s APNSClientConfiguration combines an authentication method with an environment, and an APNSClient owns the HTTP/2 connections for that pair. VaporAPNS stores clients in APNSContainers, keyed by APNSContainers.ID; the package predefines the IDs .default, .production and .development. app.apns.configure(_:) registers two containers, .production and .development, from one authentication method, which is what an app that serves both Xcode and TestFlight builds needs.

The listing below reads the key from the environment, never from the repository, and fails the boot with a readable message when anything is missing or malformed.

import APNS
import APNSCore
import Foundation
import Vapor
import VaporAPNS

/// Non-secret push settings, kept in `app.storage` next to the APNs containers.
struct PushSettings: Sendable {
    /// The app's bundle identifier: the `apns-topic` of every notification.
    let topic: String
}

extension Application {
    private struct PushSettingsKey: StorageKey {
        typealias Value = PushSettings
    }

    var pushSettings: PushSettings? {
        get { storage[PushSettingsKey.self] }
        set { storage[PushSettingsKey.self] = newValue }
    }
}

enum APNSSetupError: Error, CustomStringConvertible {
    case missing(String)
    case unreadableKey(String)

    var description: String {
        switch self {
        case .missing(let name): "\(name) is not set"
        case .unreadableKey(let why): "APNS signing key unreadable: \(why)"
        }
    }
}

/// Reads the PEM text of the `.p8` key from the environment, never from the repository.
///
/// - `APNS_KEY_PATH`: a file (a Docker/Kubernetes secret mounted read-only, or a Fly.io secret file)
/// - `APNS_KEY_P8`: the PEM text itself; literal `\n` sequences are turned into newlines
/// - `APNS_KEY_P8_BASE64`: the whole PEM base64-encoded, for platforms that dislike newlines
func loadAPNSKeyPEM() throws -> String {
    if let path = Environment.get("APNS_KEY_PATH") {
        guard let pem = try? String(contentsOfFile: path, encoding: .utf8) else {
            throw APNSSetupError.unreadableKey("cannot read \(path)")
        }
        return pem
    }
    if let inline = Environment.get("APNS_KEY_P8") {
        return inline.replacingOccurrences(of: "\\n", with: "\n")
    }
    if let encoded = Environment.get("APNS_KEY_P8_BASE64") {
        guard let data = Data(base64Encoded: encoded) else {
            throw APNSSetupError.unreadableKey("APNS_KEY_P8_BASE64 is not valid base64")
        }
        return String(decoding: data, as: UTF8.self)
    }
    throw APNSSetupError.missing("APNS_KEY_PATH, APNS_KEY_P8 or APNS_KEY_P8_BASE64")
}

/// Registers a development and a production APNs client that share one signing key.
func configureAPNS(_ app: Application) async throws {
    guard let keyID = Environment.get("APNS_KEY_ID") else { throw APNSSetupError.missing("APNS_KEY_ID") }
    guard let teamID = Environment.get("APNS_TEAM_ID") else { throw APNSSetupError.missing("APNS_TEAM_ID") }
    guard let topic = Environment.get("APNS_TOPIC") else { throw APNSSetupError.missing("APNS_TOPIC") }

    // `.init(pemRepresentation:)` resolves to swift-crypto's `P256.Signing.PrivateKey`; a malformed key throws here, at boot.
    await app.apns.configure(.jwt(
        privateKey: try .init(pemRepresentation: try loadAPNSKeyPEM()),
        keyIdentifier: keyID,
        teamIdentifier: teamID
    ))
    app.pushSettings = PushSettings(topic: topic)
}

Points worth knowing about this code:

  • .init(pemRepresentation:) is swift-crypto’s P256.Signing.PrivateKey initializer, resolved by the parameter type, so the file needs no import Crypto. A .p8 file is a PKCS#8 PEM document, which is what it parses. A malformed key throws at boot, which is where you want to learn about it. The older P256.Signing.PrivateKey.loadFrom(string:) shown in the Vapor docs is deprecated in APNSwift 7.

  • app.apns.configure(_:) and app.apns.containers.use(...) are async, because APNSContainers is an actor. The Vapor docs snippet calls containers.use without await; with the 5.x package that no longer compiles.

  • Reading app.apns.client or app.apns.client(_:) when the container was never registered stops the process with a fatalError ("No default APNS container configured." or "No APNS container for …​"); there is no thrown error to catch. Configure before the first request, in configure.

  • The first container registered becomes the default one (isDefault: overrides that), which is what req.apns.client returns. With configure(_:) that is .production; the code below always asks for a container explicitly, by the token’s recorded environment.

  • The client is created with eventLoopGroupProvider: .shared(app.eventLoopGroup), so it uses Vapor’s event loops, and it is shut down by Vapor when the application shuts down (APNSContainers is registered with setWithAsyncShutdown). Create one client per environment, once; do not build a client per request, because each one owns an HTTP client and its connection pool.

  • The JWT is not signed per request. APNSwift’s APNSAuthenticationTokenManager caches the signed token and creates a new one after 55 minutes. Apple rejects tokens older than an hour and answers 429 TooManyProviderTokenUpdates if a provider refreshes more often than about every 20 minutes, so a hand-written sender that signs a token for every notification breaks as soon as it has any traffic.

  • The container API also accepts your own JSONDecoder and JSONEncoder (app.apns.containers.use(config, eventLoopGroupProvider: .shared(app.eventLoopGroup), responseDecoder: JSONDecoder(), requestEncoder: JSONEncoder(), as: .development)), which is how the tests below point a container at the mock server.

Keeping the .p8 key a secret

The key can sign pushes for every app of the team, so treat it like a database password:

  • Never commit it. Add *.p8 to .gitignore, and keep it out of Docker build contexts (.dockerignore) and images; build-time COPY puts it in an image layer for good.

  • Inject it at deploy time, in the form the platform handles best. The loader above accepts all three: a file (APNS_KEY_PATH), the PEM text (APNS_KEY_P8, where literal \n sequences are turned into newlines), or base64 of the whole PEM (APNS_KEY_P8_BASE64) for platforms and .env files that dislike multi-line values.

  • Prefer a file from a secret volume where the platform offers one: it never appears in ps output, docker inspect or crash dumps of the environment.

  • Keep the key ID, team ID and topic in plain configuration; they are not secret, but do keep them separate per environment if you use separate keys.

  • Rotate by creating a second key in the developer account, deploying it with its new key ID, and then revoking the old one. APNs accepts either key until it is revoked.

# Kubernetes: a secret from the key file, mounted read-only; the env vars carry only the non-secret values.
kubectl create secret generic bookshelf-apns --from-file=AuthKey_ABC123DEFG.p8=./AuthKey_ABC123DEFG.p8
# In the Deployment: mount the secret at /run/secrets/apns and set
#   APNS_KEY_PATH=/run/secrets/apns/AuthKey_ABC123DEFG.p8  APNS_KEY_ID=ABC123DEFG
#   APNS_TEAM_ID=DEF123GHIJ                                APNS_TOPIC=com.example.bookshelf

# Fly.io (or any platform with string secrets): base64 keeps the multi-line PEM on one line.
fly secrets set APNS_KEY_ID=ABC123DEFG APNS_TEAM_ID=DEF123GHIJ APNS_TOPIC=com.example.bookshelf \
  APNS_KEY_P8_BASE64="$(base64 < AuthKey_ABC123DEFG.p8 | tr -d '\n')"

The three input forms and the failure messages were run: APNS_KEY_PATH, APNS_KEY_P8 and APNS_KEY_P8_BASE64 all boot (checked with swift run App routes and a throwaway key generated with openssl), a missing variable stops the boot with "APNS_KEY_ID is not set", and a damaged key stops it with CryptoKit.CryptoKitASN1Error.invalidPEMDocument. The Kubernetes and Fly.io commands themselves were not run against a cluster or an account. Environment handling in general is covered on Configuration and Environment, and the broader secrets checklist on Security Hardening.

Storing and registering device tokens

The server needs the token of every device it may notify, tied to a user, and it needs to forget tokens when they stop working. The model keeps three kinds of token in one table, because all of them are "an opaque string APNs gave the app":

  • device: the token from didRegisterForRemoteNotificationsWithDeviceToken, used for alerts and background pushes

  • liveActivity: the push token of one running Live Activity (here: one loan), used to update or end it

  • pushToStart: the token that lets the server start a Live Activity the app is not currently running

import Fluent
import Foundation
import Vapor

/// Which APNs endpoint a token was issued for. An Xcode build registers a development token,
/// TestFlight and App Store builds register a production token.
enum PushEnvironment: String, Codable, Sendable {
    case development, production
}

/// What the token addresses: the app on a device, one running Live Activity, or "start a Live Activity".
enum PushTokenKind: String, Codable, Sendable {
    case device, liveActivity, pushToStart
}

final class PushToken: Model, @unchecked Sendable {
    static let schema = "push_tokens"

    @ID(key: .id) var id: UUID?
    @Parent(key: "user_id") var user: User
    @Field(key: "token") var token: String
    @Enum(key: "environment") var environment: PushEnvironment
    @Enum(key: "kind") var kind: PushTokenKind
    /// Set for `.liveActivity` tokens: the loan the activity shows.
    @OptionalField(key: "loan_id") var loanID: UUID?
    /// When the app last told us about this token. A 410 response older than this is stale.
    @Field(key: "registered_at") var registeredAt: Date

    init() {}

    init(userID: User.IDValue, token: String, environment: PushEnvironment,
         kind: PushTokenKind = .device, loanID: UUID? = nil, registeredAt: Date = .now) {
        self.$user.id = userID
        self.token = token
        self.environment = environment
        self.kind = kind
        self.loanID = loanID
        self.registeredAt = registeredAt
    }
}

struct CreatePushToken: AsyncMigration {
    func prepare(on db: any Database) async throws {
        let environment = try await db.enum("push_environment")
            .case("development").case("production").create()
        let kind = try await db.enum("push_token_kind")
            .case("device").case("liveActivity").case("pushToStart").create()
        try await db.schema(PushToken.schema)
            .id()
            .field("user_id", .uuid, .required, .references("users", "id", onDelete: .cascade))
            .field("token", .string, .required)
            .field("environment", environment, .required)
            .field("kind", kind, .required)
            .field("loan_id", .uuid)
            .field("registered_at", .datetime, .required)
            .unique(on: "token")
            .create()
    }

    func revert(on db: any Database) async throws {
        try await db.schema(PushToken.schema).delete()
        try await db.enum("push_token_kind").delete()
        try await db.enum("push_environment").delete()
    }
}

Models and migrations are covered on Fluent Models and Migrations and Schema; the table has a unique constraint on token, so the same string can never belong to two rows. The controller below is the registration endpoint, the sign-out endpoint and a librarian-only endpoint that sends a reminder for one loan.

import Fluent
import Foundation
import Vapor

struct RegisterPushTokenRequest: Content {
    var token: String
    var environment: PushEnvironment
    var kind: PushTokenKind?
    var loanID: UUID?
}

/// APNs device tokens are hex strings. Apple does not promise a length, so check the alphabet and a sane range.
func normalizedPushToken(_ raw: String) throws -> String {
    let token = raw.lowercased()
    guard (32...200).contains(token.count), token.count.isMultiple(of: 2), token.allSatisfy(\.isHexDigit) else {
        throw Abort(.unprocessableEntity, reason: "token must be a hexadecimal APNs token")
    }
    return token
}

struct PushController: RouteCollection {
    func boot(routes: any RoutesBuilder) throws {
        let devices = routes.grouped("v1", "devices")
        devices.put(use: register)
        devices.delete(":token", use: unregister)
        routes.post("v1", "loans", ":loanID", "remind", use: remind)
    }

    /// PUT /v1/devices: idempotent, called every time the app receives a token.
    @Sendable
    func register(req: Request) async throws -> HTTPStatus {
        let user = try req.auth.require(User.self)
        let body = try req.content.decode(RegisterPushTokenRequest.self)
        let token = try normalizedPushToken(body.token)
        let kind = body.kind ?? .device
        if kind == .liveActivity {
            guard let loanID = body.loanID,
                  let loan = try await Loan.find(loanID, on: req.db),
                  loan.$user.id == user.id
            else { throw Abort(.unprocessableEntity, reason: "a Live Activity token needs one of your loans") }
            _ = loan
        }

        if let existing = try await PushToken.query(on: req.db).filter(\.$token == token).first() {
            // The same device after another sign-in: the token moves to the new user.
            existing.$user.id = try user.requireID()
            existing.environment = body.environment
            existing.kind = kind
            existing.loanID = body.loanID
            existing.registeredAt = .now
            try await existing.save(on: req.db)
            return .ok
        }
        let created = PushToken(userID: try user.requireID(), token: token, environment: body.environment,
                                kind: kind, loanID: body.loanID)
        try await created.save(on: req.db)
        return .created
    }

    /// DELETE /v1/devices/:token: call it from the sign-out flow.
    @Sendable
    func unregister(req: Request) async throws -> HTTPStatus {
        let user = try req.auth.require(User.self)
        let token = try normalizedPushToken(req.parameters.require("token"))
        try await PushToken.query(on: req.db)
            .filter(\.$token == token)
            .filter(\.$user.$id == user.requireID())
            .delete()
        return .noContent
    }

    /// POST /v1/loans/:loanID/remind: what a due-date job calls for each loan; here it is a librarian action.
    @Sendable
    func remind(req: Request) async throws -> RemindResponse {
        let user = try req.auth.require(User.self)
        guard user.role != .reader else { throw Abort(.forbidden) }
        guard let loan = try await Loan.query(on: req.db)
            .filter(\.$id == req.parameters.require("loanID", as: UUID.self))
            .with(\.$book)
            .first()
        else { throw Abort(.notFound) }
        let push = try PushService(app: req.application, logger: req.logger)
        let outcome = try await push.sendLoanDue(loan, bookTitle: loan.book.title, on: req.db)
        return RemindResponse(delivered: outcome.delivered, pruned: outcome.pruned, retryable: outcome.retryable, dropped: outcome.dropped)
    }
}

struct RemindResponse: Content {
    let delivered: Int
    let pruned: Int
    let retryable: Int
    let dropped: Int
}

The handlers read req.auth.require(User.self), so register the controller on a route group that carries your authenticator and User.guardMiddleware(), as described on Authentication and Fluent Authentication and Tokens. The example conforms User to Authenticatable in one line (extension User: Authenticatable {}), which those pages define for you.

The behaviours that matter, and that a naive endpoint gets wrong:

  • Idempotent upsert. The app receives a token on every launch, and it can change at any time (restoring a backup, reinstalling, or Apple rotating it), so the endpoint is PUT and answers 201 the first time and 200 after that. Refresh registeredAt every time: the 410 handling below uses it.

  • The token follows the person who signed in. If another account signs in on the same phone, the same token arrives for a different user. Reassigning the row stops the previous owner’s reminders from appearing on someone else’s lock screen.

  • Delete on sign-out. The sign-out flow of the app should call DELETE /v1/devices/:token before it discards its session.

  • Normalise and validate. Apple’s documentation does not promise a token length, so the validation checks the alphabet (hex), an even length and a generous range instead of exactly 64 characters. Lower-casing matches the %02x formatting the Swift side uses.

  • Check ownership of what a token refers to. A Live Activity token is only accepted for a loan that belongs to the caller.

  • Record the environment. The app knows which one it is (an aps-environment entitlement of development or production) and sends it; the server cannot infer it from the token.

  • The unique constraint is the safety net. Two concurrent first registrations of the same token race between the lookup and the insert; the loser fails with a constraint error and the app simply retries on its next launch.

A client-side PUT looks like this (the token is the placeholder ab repeated; real ones differ):

curl -X PUT http://localhost:8080/v1/devices \
  -H 'Authorization: Bearer <BOOKSHELF_ACCESS_TOKEN>' -H 'Content-Type: application/json' \
  -d '{"token":"abababababababababababababababababababababababababababababababab","environment":"development"}'

Sending notifications

PushService is the one place that talks to APNs. It is a Sendable struct that holds the Application, the topic and a Logger, reads the borrower’s tokens, picks the client by each token’s recorded environment, and turns every APNs result into a counter and, for dead tokens, a delete. The listing contains the alert, the background push, the two Live Activity pushes and the response handling; each part is discussed below.

import APNS
import APNSCore
import Fluent
import Foundation
import Logging
import Vapor
import VaporAPNS

/// Custom data that rides next to the `aps` dictionary. Never use the key `aps` here.
struct LoanDuePayload: Encodable, Sendable {
    let kind = "loan-due"
    let loanID: UUID
    let bookID: UUID
}

/// A hint for a silent push: "something changed, go and sync".
struct SyncHint: Encodable, Sendable {
    let resource: String
}

/// The Live Activity `ContentState`; it must match the app's `ActivityAttributes.ContentState` key for key.
struct LoanActivityState: Encodable, Sendable {
    let title: String
    let daysLeft: Int
}

/// The static half of the Live Activity (`ActivityAttributes`); its type name is sent as `attributes-type`.
struct LoanAttributes: Encodable, Sendable {
    let loanID: UUID
}

struct PushOutcome: Sendable, Equatable {
    var delivered = 0
    var pruned = 0
    var retryable = 0
    var dropped = 0
}

struct PushService: Sendable {
    let app: Application
    let topic: String
    let logger: Logger

    init(app: Application, logger: Logger) throws {
        guard let settings = app.pushSettings else { throw Abort(.serviceUnavailable, reason: "push is not configured") }
        self.app = app
        self.topic = settings.topic
        self.logger = logger
    }

    private func client(for environment: PushEnvironment) async -> APNSGenericClient {
        await app.apns.client(environment == .production ? .production : .development)
    }

    // MARK: - Alert

    func sendLoanDue(_ loan: Loan, bookTitle: String, on db: any Database) async throws -> PushOutcome {
        let tokens = try await PushToken.query(on: db)
            .filter(\.$user.$id == loan.$user.id)
            .filter(\.$kind == .device)
            .all()
        let loanID = try loan.requireID()
        var notification = APNSAlertNotification(
            alert: .init(
                title: .raw("Loan due soon"),
                body: .raw("\"\(bookTitle)\" is due back on \(loan.dueAt.formatted(date: .abbreviated, time: .omitted)).")
            ),
            expiration: .timeIntervalSince1970InSeconds(Int(loan.dueAt.timeIntervalSince1970)),
            priority: .immediately,
            topic: topic,
            payload: LoanDuePayload(loanID: loanID, bookID: loan.$book.id),
            sound: .default,
            threadID: "loan-\(loanID)",
            category: "LOAN_DUE"
        )
        notification.collapseID = "loan-\(loanID)" // a newer reminder replaces the older one on the lock screen
        var outcome = PushOutcome()
        for token in tokens {
            let apns = await client(for: token.environment)
            await record(&outcome, for: token, on: db) {
                try await apns.sendAlertNotification(notification, deviceToken: token.token)
            }
        }
        return outcome
    }

    // MARK: - Background

    func sendSyncHint(to userID: User.IDValue, resource: String, on db: any Database) async throws -> PushOutcome {
        let tokens = try await PushToken.query(on: db)
            .filter(\.$user.$id == userID)
            .filter(\.$kind == .device)
            .all()
        let notification = APNSBackgroundNotification(
            expiration: .immediately,
            topic: topic,
            payload: SyncHint(resource: resource)
        )
        var outcome = PushOutcome()
        for token in tokens {
            let apns = await client(for: token.environment)
            await record(&outcome, for: token, on: db) {
                try await apns.sendBackgroundNotification(notification, deviceToken: token.token)
            }
        }
        return outcome
    }

    // MARK: - Live Activity

    func updateLoanActivity(_ loan: Loan, bookTitle: String, ending: Bool, on db: any Database) async throws -> PushOutcome {
        let loanID = try loan.requireID()
        let tokens = try await PushToken.query(on: db)
            .filter(\.$kind == .liveActivity)
            .filter(\.$loanID == loanID)
            .all()
        let now = Int(Date.now.timeIntervalSince1970)
        let daysLeft = max(0, Calendar.current.dateComponents([.day], from: .now, to: loan.dueAt).day ?? 0)
        let notification = APNSLiveActivityNotification(
            expiration: .none,
            priority: ending ? .immediately : .consideringDevicePower,
            appID: topic,
            contentState: LoanActivityState(title: bookTitle, daysLeft: daysLeft),
            event: ending ? .end : .update,
            timestamp: now,
            dismissalDate: ending ? .immediately : .none,
            staleDate: now + 6 * 3600
        )
        var outcome = PushOutcome()
        for token in tokens {
            let apns = await client(for: token.environment)
            await record(&outcome, for: token, on: db) {
                try await apns.sendLiveActivityNotification(notification, deviceToken: token.token)
            }
        }
        return outcome
    }

    /// Starts a Live Activity remotely with a push-to-start token the app registered earlier.
    func startLoanActivity(_ loan: Loan, bookTitle: String, on db: any Database) async throws -> PushOutcome {
        let loanID = try loan.requireID()
        let tokens = try await PushToken.query(on: db)
            .filter(\.$user.$id == loan.$user.id)
            .filter(\.$kind == .pushToStart)
            .all()
        let notification = APNSStartLiveActivityNotification(
            expiration: .none,
            priority: .immediately,
            appID: topic,
            contentState: LoanActivityState(title: bookTitle, daysLeft: 2),
            timestamp: Int(Date.now.timeIntervalSince1970),
            attributes: LoanAttributes(loanID: loanID),
            attributesType: "LoanAttributes",
            alert: .init(title: .raw("Loan due soon"), body: .raw(bookTitle))
        )
        var outcome = PushOutcome()
        for token in tokens {
            let apns = await client(for: token.environment)
            await record(&outcome, for: token, on: db) {
                try await apns.sendStartLiveActivityNotification(notification, pushToStartToken: token.token)
            }
        }
        return outcome
    }

    // MARK: - Outcome handling

    /// Runs one send and turns every result into a counter and, for dead tokens, a delete.
    private func record(
        _ outcome: inout PushOutcome,
        for token: PushToken,
        on db: any Database,
        _ send: () async throws -> APNSResponse
    ) async {
        do {
            let response = try await send()
            outcome.delivered += 1
            logger.debug("accepted by APNs", metadata: ["apns-id": "\(response.apnsID?.uuidString ?? "-")"])
        } catch let error as APNSError {
            switch error.responseStatus {
            case 410:
                // Unregistered or ExpiredToken. Keep the row if the app re-registered after APNs last saw it fail.
                if let failedAt = error.timestamp, token.registeredAt > failedAt {
                    outcome.dropped += 1
                } else {
                    try? await token.delete(on: db)
                    outcome.pruned += 1
                }
            case 429, 500, 503:
                outcome.retryable += 1
                logger.warning("APNs asked us to retry", metadata: ["status": "\(error.responseStatus)", "reason": "\(error.reason?.reason ?? "-")"])
            case 403:
                // A wrong key, team or topic is a deployment bug, not a bad device.
                outcome.dropped += 1
                logger.error("APNs rejected our credentials", metadata: ["reason": "\(error.reason?.reason ?? "-")"])
            default:
                outcome.dropped += 1
                logger.warning("APNs rejected a notification", metadata: ["status": "\(error.responseStatus)", "reason": "\(error.reason?.reason ?? "-")"])
            }
        } catch {
            // Connection reset, TLS or DNS trouble: the notification may not have reached APNs at all.
            outcome.retryable += 1
            logger.warning("could not reach APNs", metadata: ["error": "\(error)"])
        }
    }
}

Alert notifications and custom payloads

APNSAlertNotification<Payload> models the aps dictionary and carries a payload of your own. APNSwift encodes the payload at the root of the JSON, next to aps, so the payload type must not use the key aps. The type must be Encodable & Sendable; there is an overload without a payload for pushes that need none.

Parameter or property JSON key or header and meaning

alert (title, subtitle, body)

aps.alert. .raw("text") sends text as is, .localized(key:arguments:) sends a loc-key that the app resolves from its own strings, so the user’s language wins over the server’s.

sound, badge

aps.sound (.default, .fileName(_:), .critical(fileName:volume:)), aps.badge. The server owns the badge count only if it tracks unread items per user; otherwise leave it out.

threadID, category

aps.thread-id groups notifications on the lock screen; aps.category selects the action buttons the app registered.

mutableContent, interruptionLevel, relevanceScore

aps.mutable-content: 1 routes the push through the app’s notification service extension; the level is .passive, .active, .timeSensitive or .critical (the last two need entitlements or permission on the device).

expiration

apns-expiration. .none omits the header (APNs delivers once, immediately), .immediately is 0 (do not store), .timeIntervalSince1970InSeconds(_:) keeps the push until that time. A reminder that is useless after the due date should expire then.

priority

apns-priority: .immediately is 10, .consideringDevicePower is 5, .prioritizeDevicePower is 1. Priority 10 is for pushes that show something to the person right now.

topic

apns-topic, the bundle ID. Live Activity pushes use bundle ID + ".push-type.liveactivity"; the appID: initializers append that suffix for you.

collapseID (a var, set after the initializer)

apns-collapse-id, at most 64 bytes. A later push with the same ID replaces the earlier one on the device instead of stacking.

apnsID

apns-id, a UUID you choose to correlate with logs; APNs generates one when omitted and returns it in APNSResponse.

The wire format of the reminder above, captured from the mock server (the authorization header is a JWT signed with a throwaway key generated by the test; only its first characters are shown):

headers: type=alert topic=com.example.bookshelf priority=10 expiration=1792430795 collapse=loan-B162353F-2484-4D71-9A36-3E918FA21864
authorization: bearer ewo...
{
  "aps": {
    "alert": { "title": "Loan due soon", "body": "\"The Dispossessed\" is due back on 19 oct 2026." },
    "sound": "default",
    "thread-id": "loan-B162353F-2484-4D71-9A36-3E918FA21864",
    "category": "LOAN_DUE"
  },
  "kind": "loan-due",
  "loanID": "B162353F-2484-4D71-9A36-3E918FA21864",
  "bookID": "7388A4F3-DEB8-4CE8-A591-1EF463A41172"
}

The date text in the body came out as "19 oct 2026" because formatted(date:time:) uses the server’s locale; a notification whose language depends on where the server runs is a bug, which is why .localized(key:arguments:) is usually the better choice for anything the user reads. The whole payload, aps included, must stay within 4 KB (5 KB for VoIP); APNs answers 413 PayloadTooLarge otherwise. Send identifiers and a short hint, and let the app fetch details from the API.

Background (silent) notifications

A background push wakes the app so it can sync, and shows nothing. APNSBackgroundNotification sends aps.content-available: 1 plus your payload, with push type background and an apns-priority of 5 that the package fixes for you (Apple requires 5 for this type). The system throttles silent pushes and does not deliver them at all to an app the user force-quit, so treat them as a hint and never as the only way a client learns about a change; the app should also sync on launch and on a timer.

background topic=com.example.bookshelf priority=5 payload={"resource":"reviews","aps":{"content-available":1}}

Live Activity notifications

Live Activity pushes use push type liveactivity and their own topic suffix. APNSLiveActivityNotification updates or ends a running activity (event: .update or .end, with contentState, a timestamp, an optional staleDate and, for .end, a dismissalDate), addressed to the activity’s push token. APNSStartLiveActivityNotification starts one from a push-to-start token and also carries the attributes and the attributesType name of the app’s ActivityAttributes. contentState must encode to exactly the JSON that the app’s ActivityAttributes.ContentState decodes (key names and value formats match, and dates use the encoder’s default numeric representation unless you configure the container’s JSONEncoder and the app’s decoder alike).

liveactivity topic=com.example.bookshelf.push-type.liveactivity priority=5 payload={"aps":{"event":"update","timestamp":1792258076,"content-state":{"title":"The Dispossessed","daysLeft":1},"stale-date":1792279676}}
liveactivity topic=com.example.bookshelf.push-type.liveactivity priority=10 payload={"aps":{"event":"start","timestamp":1792258076,"alert":{"title":"Loan due soon","body":"The Dispossessed"},"content-state":{"title":"The Dispossessed","daysLeft":2},"attributes-type":"LoanAttributes","attributes":{"loanID":"9A32D513-8C11-4E6D-8622-3F3F4C128A12"}}}

Source: Apple: Starting and updating Live Activities with ActivityKit push notifications, Notifications, APNs and Live Activities (the app side, including how the activity and push-to-start tokens are obtained)

Updates with priority 10 count against a per-hour budget that Apple does not publish as a fixed number, so the example uses 5 for routine updates and 10 only when the activity ends. APNSwift 7.1.0 also has broadcast push (APNSBroadcastClient, channels that many activities subscribe to) in the same package; it is not covered here.

Reading APNs responses

send returns an APNSResponse (the apns-id and, on newer APNs, an apns-unique-id) for a 200, and throws APNSError for everything else. APNSError has responseStatus, reason (a typed ErrorReason whose reason string is Apple’s, such as Unregistered), apnsID and timestamp. A thrown error that is not an APNSError is a transport failure: the connection failed or was reset, and the notification may or may not have reached Apple.

Status Typical reasons What the server should do

200

accepted

Count it. Accepted means APNs has the push, not that the user saw it.

400

BadDeviceToken, DeviceTokenNotForTopic, BadTopic, PayloadEmpty, BadCollapseId, BadPriority, InvalidPushType

A bug or a wrong environment more often than a dead device. Log the reason, fix the cause, and do not retry the same request. BadDeviceToken for a sandbox token sent to production (or the reverse) is the classic case.

403

InvalidProviderToken, ExpiredProviderToken, MissingProviderToken, TopicDisallowed

Your credentials or topic are wrong for this app. Page someone; every push will fail until a deploy fixes it. Do not delete tokens.

410

Unregistered, ExpiredToken

The token is no longer valid for this topic (app uninstalled, token rotated). Delete it, unless the app re-registered it after timestamp.

413

PayloadTooLarge

Shrink the payload.

429

TooManyRequests (many pushes to one token), TooManyProviderTokenUpdates

Back off and retry later.

500, 503

InternalServerError, ServiceUnavailable, Shutdown

Retry with exponential backoff and jitter.

The record method of PushService implements that table. The 410 rule deserves its own explanation. The timestamp in a 410 is the last time APNs confirmed that the token was invalid. If your registeredAt is newer, the app has registered the token again since then, so the failure is stale and the row stays; deleting it would silently stop notifications for a working device. The test below runs both branches against the mock server, which answers 410 Unregistered for a token of 64 f characters and returns a fixed old timestamp:

outcome: PushOutcome(delivered: 1, pruned: 1, retryable: 0, dropped: 0)
outcome (re-registered): PushOutcome(delivered: 1, pruned: 0, retryable: 0, dropped: 1)

A retryable outcome is returned to the caller instead of being retried inline, on purpose: the right place for retries with backoff is a background job, so that a slow or unavailable APNs never holds a request open and a crash does not lose the work. Wire sendLoanDue into the jobs of the Queues & Background Jobs page and the Scheduled Jobs page, pass only the loan ID in the job payload, and re-read the tokens when the job runs. Delivery guarantees (APNs is at-most-once from your point of view, and a push may be coalesced or dropped by the device) are discussed on Delivery Guarantees.

Where the push happens in a request

Sending from a request handler is fine for a rare, operator-triggered action like the remind endpoint above. For anything fan-out shaped (every loan due tomorrow), do not loop over thousands of tokens inside a request: the response waits for APNs, and the event loop-friendly await inside a handler still ties up the connection and the client’s timeout. Enqueue a job per loan (or per batch), let the worker call PushService, and use req.logger or the job’s logger so a push can be traced to the request or job that caused it. PushService is deliberately independent of Request, which is what allows that: it needs an Application, a Logger and a Database.

For the same reason the service is created per use from app.pushSettings, a small StorageKey extension of the kind described on Services, Storage and Dependency Injection, and not a global.

Testing without Apple

APNSTestServer ships with APNSwift as its own product; the general VaporTesting setup is on the Testing page. It listens on 127.0.0.1, validates the headers and the structure of the JWT, records every notification, answers 410 Unregistered for the token APNSTestServer.unregisteredDeviceToken (64 f characters), and lets a test force the next response (setResponseOverride). You register a container whose environment is .custom(url:port:) and run the same service code against it. It speaks plain HTTP/1.1, so it exercises your code, the encoding and the headers, but not TLS, HTTP/2 or Apple’s real validation of the key and the token.

@testable import App
import APNS
import APNSCore
import APNSTestServer
import Crypto
import Fluent
import FluentSQLiteDriver
import Foundation
import Testing
import Vapor
import VaporAPNS
import VaporTesting

/// Signs every request in as the given user, standing in for the app's real authenticator.
struct SignedInAs: AsyncMiddleware {
    let user: User
    func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
        request.auth.login(user)
        return try await next.respond(to: request)
    }
}

@Suite("APNs push", .serialized)
struct PushTests {
    /// An app wired to a local mock APNs server instead of api.sandbox.push.apple.com.
    private func withApp(_ body: (Application, APNSTestServer, User, Loan) async throws -> Void) async throws {
        let server = APNSTestServer()
        try await server.start()
        let app = try await Application.make(.testing)
        do {
            app.databases.use(.sqlite(.memory), as: .sqlite)
            app.migrations.add(CreateBookshelf())
            app.migrations.add(CreatePushToken())
            try await app.autoMigrate()

            // A throwaway key generated here; real keys come from the environment.
            let config = APNSClientConfiguration(
                authenticationMethod: .jwt(
                    privateKey: P256.Signing.PrivateKey(),
                    keyIdentifier: "ABC123DEFG",
                    teamIdentifier: "DEF123GHIJ"
                ),
                environment: .custom(url: "http://127.0.0.1", port: server.port)
            )
            await app.apns.containers.use(
                config,
                eventLoopGroupProvider: .shared(app.eventLoopGroup),
                responseDecoder: JSONDecoder(),
                requestEncoder: JSONEncoder(),
                as: .development
            )
            app.pushSettings = PushSettings(topic: "com.example.bookshelf")

            let author = Author(name: "Ursula K. Le Guin"); try await author.save(on: app.db)
            let book = Book(title: "The Dispossessed", isbn: "978-0-06-051275-4", publishedYear: 1974,
                            priceCents: 1299, status: .published, authorID: try author.requireID())
            try await book.save(on: app.db)
            let librarian = User(email: "lib@example.com", passwordHash: "x", role: .librarian)
            try await librarian.save(on: app.db)
            let loan = Loan(bookID: try book.requireID(), userID: try librarian.requireID(),
                            loanedAt: .now, dueAt: .now.addingTimeInterval(2 * 86_400))
            try await loan.save(on: app.db)

            try app.grouped(SignedInAs(user: librarian)).register(collection: PushController())
            try await body(app, server, librarian, loan)
        } catch {
            try? await app.asyncShutdown()
            try? await server.shutdown()
            throw error
        }
        try await app.asyncShutdown()
        try await server.shutdown()
    }

    private let goodToken = String(repeating: "ab", count: 32)

    @Test("register is idempotent and rejects non-hex tokens")
    func registration() async throws {
        try await withApp { app, _, _, _ in
            let body = RegisterPushTokenRequest(token: goodToken.uppercased(), environment: .development)
            try await app.test(.PUT, "v1/devices", beforeRequest: { try $0.content.encode(body) }) { res async in
                #expect(res.status == .created)
            }
            try await app.test(.PUT, "v1/devices", beforeRequest: { try $0.content.encode(body) }) { res async in
                #expect(res.status == .ok)
            }
            #expect(try await PushToken.query(on: app.db).count() == 1)
            #expect(try await PushToken.query(on: app.db).first()?.token == goodToken)

            let bad = RegisterPushTokenRequest(token: "not-a-token", environment: .development)
            try await app.test(.PUT, "v1/devices", beforeRequest: { try $0.content.encode(bad) }) { res async in
                #expect(res.status == .unprocessableEntity)
            }
            try await app.test(.DELETE, "v1/devices/\(goodToken)") { res async in
                #expect(res.status == .noContent)
            }
            #expect(try await PushToken.query(on: app.db).count() == 0)
        }
    }

    @Test("the reminder reaches APNs with the right headers and payload")
    func alert() async throws {
        try await withApp { app, server, user, loan in
            try await PushToken(userID: user.requireID(), token: goodToken, environment: .development).save(on: app.db)
            try await app.test(.POST, "v1/loans/\(loan.requireID())/remind") { res async throws in
                #expect(res.status == .ok)
                print("remind ->", res.body.string)
                #expect(try res.content.decode(RemindResponse.self).delivered == 1)
            }
            let sent = try #require(server.getSentNotifications().first)
            print("headers: type=\(sent.pushType ?? "-") topic=\(sent.topic ?? "-") priority=\(sent.priority ?? "-") expiration=\(sent.expiration ?? "-") collapse=\(sent.collapseID ?? "-")")
            print("authorization:", String((sent.authorization ?? "").prefix(10)) + "...")
            print("payload:", String(decoding: sent.payload, as: UTF8.self))
            #expect(sent.pushType == "alert")
            #expect(sent.topic == "com.example.bookshelf")
            #expect(sent.priority == "10")
            #expect(sent.deviceToken == goodToken)
        }
    }

    @Test("a 410 deletes the token unless the app registered after the failure")
    func unregistered() async throws {
        try await withApp { app, server, user, loan in
            let dead = String(repeating: "f", count: 64) // the mock server answers 410 for this token
            try await PushToken(userID: user.requireID(), token: dead, environment: .development,
                                registeredAt: Date(timeIntervalSince1970: 1_000_000_000)).save(on: app.db)
            try await PushToken(userID: user.requireID(), token: goodToken, environment: .development).save(on: app.db)
            let push = try PushService(app: app, logger: app.logger)
            let outcome = try await push.sendLoanDue(loan, bookTitle: "The Dispossessed", on: app.db)
            print("outcome:", outcome)
            #expect(outcome.delivered == 1)
            #expect(outcome.pruned == 1)
            #expect(try await PushToken.query(on: app.db).filter(\.$token == dead).count() == 0)

            // The same 410, but the app re-registered after APNs last saw the failure: keep the row.
            try await PushToken(userID: user.requireID(), token: dead, environment: .development).save(on: app.db)
            let again = try await push.sendLoanDue(loan, bookTitle: "The Dispossessed", on: app.db)
            print("outcome (re-registered):", again)
            #expect(again.pruned == 0)
            #expect(again.dropped == 1)
            _ = server
        }
    }

    // ... the 503, background/Live Activity and key-loading tests follow the same shape.
}

The remaining tests of the suite (the 503 case, the background and Live Activity pushes, and the key loader) follow the same shape. All six pass:

✔ Test "register is idempotent and rejects non-hex tokens" passed
✔ Test "the reminder reaches APNs with the right headers and payload" passed
✔ Test "a 410 deletes the token unless the app registered after the failure" passed
✔ Test "a 503 is counted as retryable and the token is kept" passed
✔ Test "background and Live Activity pushes use their own push types" passed
✔ Test "the signing key loader reads a file and rejects garbage" passed
✔ Test run with 6 tests in 1 suite passed

The first real send needs a real device: build the app from Xcode, run it on hardware (Apple also documents xcrun simctl push and the Push Notifications Console for trying a payload without any server), copy the token the app logs, and PUT it with "environment": "development". Apple’s curl recipe below sends one notification without any server code, which is the quickest way to tell a server bug from a credentials problem.

Swift 6 concurrency notes

  • APNSGenericClient (APNSClient<JSONDecoder, JSONEncoder>) is Sendable because the coder types are, so the client can be fetched and used from any task. APNSContainers is an actor; that is why containers.use, client and client(_:) need await.

  • Notification types are generic over your payload and are Sendable when the payload is. The payload parameter is constrained to Encodable & Sendable, which is why the payload types above are plain value types.

  • Application is Sendable, so PushService can hold it and be passed into a job; the Fluent models stay @unchecked Sendable as elsewhere in this section, and the service never lets a model leave the function that loaded it. PushOutcome is a value type, and the inout parameter of record is only touched on the calling task.

  • With NonisolatedNonsendingByDefault enabled, which vapor/apns 5.1.0 itself turns on, async functions run on the caller’s executor; nothing here depends on it.

  • Compiler errors met while writing this page: collapseID is not an initializer parameter ("extra argument 'collapseID' in call", and a follow-on "generic parameter 'Payload' could not be inferred"); set it on the var after creating the notification. The Vapor docs' deadline: argument of sendAlertNotification no longer exists in APNSwift 7.

Common errors

Symptom Cause and fix

Fatal error: No APNS container for ... or No default APNS container configured.

app.apns.client(_:) was used before the container was registered. Register in configure; a missing container cannot be caught.

CryptoKit.CryptoKitASN1Error.invalidPEMDocument at boot

The key text is not a PEM: the header lines are missing, or the newlines were lost (\n inside an env var, a copy that collapsed the lines). Use a file or APNS_KEY_P8_BASE64.

403 InvalidProviderToken or ExpiredProviderToken

Wrong key ID or team ID, a revoked key, or a server clock far off. Keep the clock synchronised; the JWT carries an issue time.

400 BadDeviceToken for every token

A development token sent to production or the other way round. Send the environment with the token and pick the client from it, as the code does.

400 DeviceTokenNotForTopic or TopicDisallowed

The token belongs to another app, or the topic (bundle ID) is not the app that registered it. Live Activity pushes need the .push-type.liveactivity suffix.

410 Unregistered for a token that works

Compare error.timestamp with the time the app last registered; the row may be newer than the failure.

429 TooManyProviderTokenUpdates

Something signs a fresh JWT for each request (a hand-rolled client). Use one long-lived APNSClient per environment.

Pushes accepted (200) but nothing appears

Notifications are disabled or in a Focus, the app is in the foreground without a delegate that presents them, a silent push was throttled, or apns-push-type does not match the payload. APNs only knows it accepted the request.

Version notes

  • vapor/apns 4.0.0 (2023-07-31) introduced the named-container API used here (the release notes mention "multiple configs" and Vapor’s HTTP client); 5.0.0 (2025-07-03) moved to APNSwift 6.1.0, and 5.1.0 (2026-10-08) to APNSwift 7.0.1 and later. In 5.1.0 APNSContainers is an actor and the app.apns.client accessors are async; the Vapor docs page still shows from: "4.0.0" and the synchronous calls, so check any snippet against the 5.1.0 source.

  • The Vapor 5 branch (vapor5) targets Vapor 5.0.0-beta.3 and newer with the same APNSwift; expect the same API surface, with Vapor 5’s own changes to Application storage and lifecycle around it.

  • APNSwift 8.0.0-beta1 (2026-10-16) is a pre-release; pin 7.1.0 for now.

  • APNSEnvironment.sandbox is deprecated in APNSwift 7 in favour of .development.

Where the books are outdated

  • None of the three consulted books covers APNs; it is one of the topics this section takes from the official documentation alone. All three predate Vapor 4’s apns package.

  • Older tutorials written before that package typically send pushes with certificate (.p12) authentication and community libraries that are no longer maintained. Today the sender is vapor/apns 5 on APNSwift 7, authentication is the .p8 key (ES256 JWT), and every call is async; replace such code with the setup on this page.