Sending APNs Push Notifications from Swift

I wrote and maintain APNSwift, the Apple Push Notification client for Swift. It's an SSWG graduated package and it's used inside Apple. So when someone asks me how to send a push notification from Swift, I have opinions about the answer.

This post is the standalone version: no web framework, no database, no app architecture. Just a Swift process that sends a notification and understands what came back. If you want the full-stack version wired into a Vapor backend with device token storage, that's a separate post.

Everything here targets APNSwift 7.0.0, which requires Swift 6 tools and builds in Swift 6 language mode. Platforms are macOS 13+, iOS 16+, watchOS 9+, tvOS 16+.

What You Need From Apple First

Before any code, you need three values and a file. Getting one wrong is the most common reason pushes silently fail.

  1. A .p8 private key file. Certificates, Identifiers & Profiles → Keys → create a key with "Apple Push Notifications service (APNs)" enabled. You can download this exactly once. If you lose it, you have to create a new key.
  2. The Key ID. 10 characters, shown next to the key. It's also in the filename: AuthKey_ABC123DEFG.p8.
  3. Your Team ID. 10 characters, on your Membership page.
  4. Your app's bundle identifier. This becomes the APNs topic, and it must match the receiving app exactly.

Use token-based (JWT) auth. The alternative is certificate-based auth, which expires annually and has to be rotated by hand. APNSwift supports both, but one APNs key works across all your apps and doesn't expire, so there's no good reason to pick certificates for new work.

Installing

// Package.swift
dependencies: [
    .package(
        url: "https://github.com/kylebrowning/APNSwift.git",
        from: "7.0.0"
    )
]

APNSwift ships four libraries, and picking the right one matters:

  • APNS - the NIO client, built on AsyncHTTPClient. This is what you want on a server.
  • APNSURLSession - a URLSession-based client with no NIO dependency. Apple platforms only.
  • APNSCore - the notification types and the client protocol. Both clients depend on it.
  • APNSTestServer - a local APNs stand-in for your test suite.

Creating a Client

import APNS
import APNSCore
import Crypto
import Foundation

let pem = try String(contentsOfFile: "AuthKey_ABC123DEFG.p8", encoding: .utf8)

let client = APNSClient(
    configuration: .init(
        authenticationMethod: .jwt(
            privateKey: try P256.Signing.PrivateKey(pemRepresentation: pem),
            keyIdentifier: "ABC123DEFG",
            teamIdentifier: "TEAM123456"
        ),
        environment: .development
    ),
    eventLoopGroupProvider: .shared(MultiThreadedEventLoopGroup.singleton),
    responseDecoder: JSONDecoder(),
    requestEncoder: JSONEncoder()
)

Some notes on that.

The client is long-lived. It owns an HTTP/2 connection pool, and APNSwift deliberately keeps that HTTPClient internal because both authentication mechanisms bind to a single connection that can't be shared. Create one at process start, reuse it, shut it down on exit. A client per notification means a TLS handshake per notification.

You must shut it down:

try await client.shutdown()

Use the singleton event loop group unless you have a reason not to. .shared(MultiThreadedEventLoopGroup.singleton) reuses the process-wide group. .createNew spins up its own, which is fine for a one-shot CLI and wasteful in a server that already has one.

P256.Signing.PrivateKey(pemRepresentation:) is the current way to load the key. If you find loadFrom(string:) in an older post, it's deprecated in favor of that initializer.

Choosing the Environment

APNSEnvironment is a struct, not an enum, with three ways to build one:

.production                              // https://api.push.apple.com
.development                             // https://api.development.push.apple.com
.custom(url: "http://127.0.0.1", port: 8080)

Note the development host is api.development.push.apple.com. .sandbox still exists as a deprecated alias for .development, so if you're on an older version and see a deprecation warning, that's the rename.

A token minted by a debug build only works against development; a token from TestFlight or the App Store only works against production. Mismatching them returns BadDeviceToken on a token that is perfectly good. Drive it from config:

let environment: APNSEnvironment =
    ProcessInfo.processInfo.environment["APNS_ENV"] == "production"
        ? .production
        : .development

.custom exists mostly for testing, which we'll use later.

Sending an Alert

struct LandmarkPayload: Codable {
    let landmarkID: String
    let action: String
}

let response = try await client.sendAlertNotification(
    .init(
        alert: .init(
            title: .raw("Landmark Updated"),
            subtitle: .raw("Bryce Canyon"),
            body: .raw("New photos have been added")
        ),
        expiration: .immediately,
        priority: .immediately,
        topic: "com.kylebrowning.landmarks",
        payload: LandmarkPayload(landmarkID: "5F2A", action: "openDetail")
    ),
    deviceToken: deviceToken
)

print(response.apnsID)        // UUID? - the ID APNs assigned
print(response.apnsUniqueID)  // UUID? - development only, for Console lookup

apnsUniqueID is worth knowing about: APNs returns it in the development environment, and you can use it to trace a specific notification in Console.app. It's nil in production.

If your notification carries no data, there's a convenience initializer that fills in EmptyPayload for you, so you can omit payload: entirely:

try await client.sendAlertNotification(
    .init(
        alert: .init(title: .raw("Landmark Updated")),
        expiration: .immediately,
        priority: .immediately,
        topic: "com.kylebrowning.landmarks"
    ),
    deviceToken: deviceToken
)

A returned response means APNs accepted the notification. It does not mean the notification arrived, or that the user saw it. APNs is best-effort and there is no delivery receipt.

Localized Alerts

.raw sends a literal string. Each of title, subtitle, and body also takes a localization key, which lets the device resolve the string against your app's Localizable.strings:

alert: .init(
    title: .localized(key: "LANDMARK_UPDATED_TITLE", arguments: []),
    body: .localized(key: "LANDMARK_UPDATED_BODY", arguments: ["Bryce Canyon"])
)

This is strictly better than localizing server-side. The device knows the user's current language; your server knows what it was at registration time, which may be stale.

Payload Encoding

Your payload encodes to the root of the JSON, alongside the reserved aps dictionary rather than inside it. That's what Apple requires. It does mean you should avoid a key named aps in your payload type, since APNSwift encodes your payload first and then overwrites aps.

Keep it small. The ceiling is 4KB for regular notifications. Send identifiers, not objects, and let the app fetch the full record when it handles the notification.

The Other Notification Types

APNSwift models each push type as its own method rather than making you set headers by hand. Beyond sendAlertNotification there's:

sendBackgroundNotification      // silent wake-to-fetch
sendLiveActivityNotification    // update or end a Live Activity
sendStartLiveActivityNotification
sendLocationNotification
sendVoIPNotification
sendPushToTalkNotification
sendComplicationNotification
sendControlsNotification        // Control Center controls
sendFileProviderNotification
sendWidgetsNotification

Background notifications wake your app with nothing shown:

try await client.sendBackgroundNotification(
    .init(
        expiration: .immediately,
        topic: "com.kylebrowning.landmarks",
        payload: LandmarkPayload(landmarkID: "5F2A", action: "sync")
    ),
    deviceToken: deviceToken
)

Note APNSwift always sends these at .consideringDevicePower priority, because Apple rejects background pushes sent at high priority. That's handled for you. iOS still throttles them aggressively - a few per hour, at the system's discretion. Never build a feature that requires one to arrive on time.

Live Activities have two distinct calls. To update or end an activity you already know about, use the activity's push token:

try await client.sendLiveActivityNotification(
    .init(
        expiration: .immediately,
        priority: .immediately,
        appID: "com.kylebrowning.landmarks",
        contentState: contentState,
        event: .update,
        timestamp: Int(Date().timeIntervalSince1970)
    ),
    deviceToken: activityPushToken
)

To start one remotely, you need the push-to-start token and both the attributes and their type name:

try await client.sendStartLiveActivityNotification(
    .init(
        expiration: .immediately,
        priority: .immediately,
        appID: "com.kylebrowning.landmarks",
        contentState: contentState,
        timestamp: Int(Date().timeIntervalSince1970),
        attributes: attributes,
        attributesType: "LandmarkActivityAttributes",
        alert: .init(
            title: .raw("Tracking Bryce Canyon"),
            body: .raw("Live updates started"),
            sound: .fileName("default.aiff")
        )
    ),
    pushToStartToken: pushToStartToken
)

Three separate tokens are in play across these APIs - the device token, the Live Activity update token, and the push-to-start token - and they are not interchangeable. Using the wrong one fails every time.

Error Handling

This is where most integrations are weakest. APNSwift throws a typed APNSError:

public struct APNSError: Error {
    public let responseStatus: Int
    public let apnsID: UUID?
    public let apnsUniqueID: UUID?
    public let reason: ErrorReason?
    public let timestamp: Date?
}

reason is an optional ErrorReason, so unwrap before switching:

do {
    try await client.sendAlertNotification(notification, deviceToken: token)
} catch let error as APNSError {
    guard let reason = error.reason else {
        logger.error("APNs failed with status \(error.responseStatus)")
        return
    }

    switch reason {
    case .badDeviceToken, .unregistered:
        // The token is dead. Delete it.
        try await deviceTokens.delete(token)

    case .payloadTooLarge:
        logger.error("Payload exceeded 4KB")

    case .tooManyRequests:
        try await Task.sleep(for: .seconds(5))

    case .expiredProviderToken, .invalidProviderToken, .missingProviderToken:
        // Your JWT is bad. Config problem, not a device problem.
        logger.critical("APNs auth failed: \(reason.errorDescription)")

    case .badEnvironmentKeyIdInToken, .badCertificateEnvironment:
        logger.critical("Environment mismatch - check development vs production")

    default:
        logger.error("APNs rejected: \(reason.reason) - \(reason.errorDescription)")
    }
}

ErrorReason gives you two useful strings: reason.reason is the raw APNs code ("BadDeviceToken"), and reason.errorDescription is a human-readable explanation. Log both. The library also carries an unknown case internally so a new APNs error code surfaces as a string rather than a crash - if you hit one, it's worth filing an issue so it can be added.

The two you must handle are badDeviceToken and unregistered. Devices get wiped, apps get deleted. If you never prune dead tokens, your database fills with garbage.

expiredProviderToken and invalidProviderToken are categorically different: your configuration is broken and every notification is failing. Alert on those, don't retry them in a loop.

The URLSession Client

If you're sending pushes from an Apple platform and don't want AsyncHTTPClient and NIO in your dependency graph, APNSURLSession implements the same APNSClientProtocol over URLSession:

import APNSURLSession

let client = APNSURLSessionClient(
    configuration: .init(
        environment: .development,
        privateKey: try P256.Signing.PrivateKey(pemRepresentation: pem),
        keyIdentifier: "ABC123DEFG",
        teamIdentifier: "TEAM123456"
    )
)

try await client.sendAlertNotification(notification, deviceToken: token)

Note the flatter configuration - no nested authenticationMethod, and no eventLoopGroupProvider or shutdown to manage. Because every send* method lives on APNSClientProtocol, all the notification-type methods above work identically on both clients.

The tradeoffs: it's JWT-only (no certificate auth), it's Apple-platforms-only, and URLSession gives you less control over connection pooling than the NIO client. For a server on Linux, use APNS. For a macOS admin tool or an integration test on a Mac, this is less machinery.

Testing Against a Local APNs

APNSwift ships APNSTestServer, which is a real local server that speaks the APNs protocol. It's how the library tests itself, and it's underused outside the repo.

import APNS
import APNSCore
import APNSTestServer
import Crypto
import NIOPosix
import XCTest

final class PushServiceTests: XCTestCase {
    var server: APNSTestServer!
    var client: APNSClient<JSONDecoder, JSONEncoder>!

    override func setUp() async throws {
        try await super.setUp()

        server = APNSTestServer()
        try await server.start(port: 0)   // 0 = pick a free port

        client = APNSClient(
            configuration: .init(
                authenticationMethod: .jwt(
                    privateKey: try P256.Signing.PrivateKey(pemRepresentation: testKey),
                    keyIdentifier: "MY_KEY_ID",
                    teamIdentifier: "MY_TEAM_ID"
                ),
                environment: .custom(url: "http://127.0.0.1", port: server.port)
            ),
            eventLoopGroupProvider: .shared(MultiThreadedEventLoopGroup.singleton),
            responseDecoder: JSONDecoder(),
            requestEncoder: JSONEncoder()
        )
    }

    override func tearDown() async throws {
        try await client?.shutdown()
        try await server?.shutdown()
        try await super.tearDown()
    }
}

.custom(url:port:) is what points the client at the local server. Then you can assert on exactly what was sent:

func testSendsCorrectHeaders() async throws {
    var alert = APNSAlertNotification(
        alert: .init(title: .raw("Landmark Updated")),
        expiration: .immediately,
        priority: .immediately,
        topic: "com.kylebrowning.landmarks",
        payload: EmptyPayload()
    )
    alert.collapseID = "landmark-5F2A"

    _ = try await client.sendAlertNotification(alert, deviceToken: validToken)

    let sent = try XCTUnwrap(server.getSentNotifications().first)
    XCTAssertEqual(sent.topic, "com.kylebrowning.landmarks")
    XCTAssertEqual(sent.pushType, "alert")
    XCTAssertEqual(sent.priority, "10")
    XCTAssertEqual(sent.collapseID, "landmark-5F2A")
}

getSentNotifications() returns records with deviceToken, pushType, topic, priority, expiration, collapseID, apnsID, authorization, and the raw payload data - plus decodedPayload(as:) to decode it into your type.

And it simulates failures, so you can test your error paths without needing a genuinely bad token:

func testDeletesTokenOnBadDeviceToken() async throws {
    do {
        _ = try await client.sendAlertNotification(alert, deviceToken: "not-valid")
        XCTFail("Expected an APNSError")
    } catch let error as APNSError {
        XCTAssertEqual(error.responseStatus, 400)
        XCTAssertEqual(error.reason, .badDeviceToken)
    }
}

This is a much better answer than "test in production and hope." Your retry logic, token pruning, and error branches all get real coverage.

For iterating on the client side, the simulator still accepts .apns files:

xcrun simctl push booted com.kylebrowning.landmarks test.apns

That never touches APNs or your server, so it validates notification handling in the app and nothing else.

Sending to Many Devices

There's no batch API in APNs for regular notifications - one request per token. The client multiplexes over HTTP/2, so concurrency is cheap:

await withTaskGroup(of: (String, Error?).self) { group in
    for token in deviceTokens {
        group.addTask {
            do {
                try await client.sendAlertNotification(notification, deviceToken: token)
                return (token, nil)
            } catch {
                return (token, error)
            }
        }
    }

    for await (token, error) in group {
        if let error { await handleFailure(token: token, error: error) }
    }
}

Catch inside the task. With a throwing task group, the first failure cancels every sibling, which is emphatically not what you want when 1 of 10,000 tokens is stale.

Bound your concurrency for large sends - an unbounded group over a million tokens tries to create a million tasks. Chunk it.

If you're broadcasting a Live Activity to many subscribers, APNSwift 7 also has channel-based broadcast support via APNSBroadcastClient and sendBroadcast. That's Live Activities only, and it's a different host and request shape than the per-device path above.

The Failure Checklist

When a notification doesn't arrive, work this list in order:

  1. Environment mismatch. Development token against production, or vice versa. Number one by a wide margin.
  2. Topic doesn't match the bundle ID.
  3. Wrong token type. Device token where a Live Activity or push-to-start token was needed.
  4. The key was revoked, or the Key ID doesn't match the .p8 you loaded.
  5. The user denied permission, or has the app in a Focus filter.
  6. The token is stale and you're discarding the unregistered error.
  7. Background push arrived but iOS throttled it.

Log reason.reason and reason.errorDescription on every failure. That single line is the difference between a five-minute fix and an afternoon.

Wrapping Up

  • One long-lived client, shut down cleanly. APNS on a server, APNSURLSession when you want to skip NIO.
  • JWT auth with a .p8 loaded via P256.Signing.PrivateKey(pemRepresentation:).
  • .development is api.development.push.apple.com. Drive it from config, and remember tokens are environment-specific.
  • Send identifiers in the payload, not objects, and never a key called aps.
  • Unwrap error.reason before switching. Delete tokens on badDeviceToken/unregistered; alert separately on invalidProviderToken.
  • Use APNSTestServer with .custom(url:port:) to test your error paths for real.

APNSwift is on GitHub with docs on Swift Package Index. If you hit something this post didn't cover, open an issue - I read them.