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:
-
The app registers with APNs and receives a device token, which identifies this app on this device (and this environment).
-
The app uploads the token to your server. That is the registration endpoint of this page.
-
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
.p8key. -
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.
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 ( |
Certificates, Identifiers and Profiles, Keys: create a key with the Apple Push Notifications service (APNs) capability. Apple lets you download the file once. |
|
Key ID |
Ten characters, shown next to the key and embedded in its file name. |
|
Team ID |
Ten characters, shown in the membership details. |
|
Topic |
The app’s bundle identifier. It becomes the |
|
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’sP256.Signing.PrivateKeyinitializer, resolved by the parameter type, so the file needs noimport Crypto. A.p8file 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 olderP256.Signing.PrivateKey.loadFrom(string:)shown in the Vapor docs is deprecated in APNSwift 7. -
app.apns.configure(_:)andapp.apns.containers.use(...)areasync, becauseAPNSContainersis anactor. The Vapor docs snippet callscontainers.usewithoutawait; with the 5.x package that no longer compiles. -
Reading
app.apns.clientorapp.apns.client(_:)when the container was never registered stops the process with afatalError("No default APNS container configured." or "No APNS container for …"); there is no thrown error to catch. Configure before the first request, inconfigure. -
The first container registered becomes the default one (
isDefault:overrides that), which is whatreq.apns.clientreturns. Withconfigure(_:)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 (APNSContainersis registered withsetWithAsyncShutdown). 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
APNSAuthenticationTokenManagercaches the signed token and creates a new one after 55 minutes. Apple rejects tokens older than an hour and answers429 TooManyProviderTokenUpdatesif 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
JSONDecoderandJSONEncoder(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
*.p8to.gitignore, and keep it out of Docker build contexts (.dockerignore) and images; build-timeCOPYputs 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\nsequences are turned into newlines), or base64 of the whole PEM (APNS_KEY_P8_BASE64) for platforms and.envfiles that dislike multi-line values. -
Prefer a file from a secret volume where the platform offers one: it never appears in
psoutput,docker inspector 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')"
Source: Kubernetes: Secrets, Fly.io: Secrets
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 fromdidRegisterForRemoteNotificationsWithDeviceToken, 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
PUTand answers201the first time and200after that. RefreshregisteredAtevery 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/:tokenbefore 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
%02xformatting 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-environmententitlement ofdevelopmentorproduction) 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 |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
|---|---|---|
|
accepted |
Count it. Accepted means APNs has the push, not that the user saw it. |
|
|
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. |
|
|
Your credentials or topic are wrong for this app. Page someone; every push will fail until a deploy fixes it. Do not delete tokens. |
|
|
The token is no longer valid for this topic (app uninstalled, token rotated). Delete it, unless the app re-registered it after |
|
|
Shrink the payload. |
|
|
Back off and retry later. |
|
|
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>) isSendablebecause the coder types are, so the client can be fetched and used from any task.APNSContainersis anactor; that is whycontainers.use,clientandclient(_:)needawait. -
Notification types are generic over your payload and are
Sendablewhen the payload is. The payload parameter is constrained toEncodable & Sendable, which is why the payload types above are plain value types. -
ApplicationisSendable, soPushServicecan hold it and be passed into a job; the Fluent models stay@unchecked Sendableas elsewhere in this section, and the service never lets a model leave the function that loaded it.PushOutcomeis a value type, and theinoutparameter ofrecordis only touched on the calling task. -
With
NonisolatedNonsendingByDefaultenabled, whichvapor/apns5.1.0 itself turns on,asyncfunctions run on the caller’s executor; nothing here depends on it. -
Compiler errors met while writing this page:
collapseIDis not an initializer parameter ("extra argument 'collapseID' in call", and a follow-on "generic parameter 'Payload' could not be inferred"); set it on thevarafter creating the notification. The Vapor docs'deadline:argument ofsendAlertNotificationno longer exists in APNSwift 7.
Common errors
| Symptom | Cause and fix |
|---|---|
|
|
|
The key text is not a PEM: the header lines are missing, or the newlines were lost ( |
|
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. |
|
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. |
|
The token belongs to another app, or the topic (bundle ID) is not the app that registered it. Live Activity pushes need the |
|
Compare |
|
Something signs a fresh JWT for each request (a hand-rolled client). Use one long-lived |
Pushes accepted ( |
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 |
Version notes
-
vapor/apns4.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.0APNSContainersis anactorand theapp.apns.clientaccessors areasync; the Vapor docs page still showsfrom: "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 toApplicationstorage and lifecycle around it. -
APNSwift 8.0.0-beta1 (2026-10-16) is a pre-release; pin 7.1.0 for now.
-
APNSEnvironment.sandboxis 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
apnspackage. -
Older tutorials written before that package typically send pushes with certificate (
.p12) authentication and community libraries that are no longer maintained. Today the sender isvapor/apns5 on APNSwift 7, authentication is the.p8key (ES256 JWT), and every call isasync; replace such code with the setup on this page.
References
-
APNSwift 7.1.0 (formerly under swift-server-community)
-
Apple: Starting and updating Live Activities with ActivityKit push notifications