Open-source project
kylebrowning/APNSwift avatar
kylebrowning/APNSwift

APNSwift sends Apple push notifications over HTTP/2 from a Swift server

📱HTTP/2 Apple Push Notification Service built with swift - send push notifications to iOS, iPadOS, tvOS, macOS, watchOS, visionOS, and Safari!

843 stars124 forksSwiftApache-2.0

At a glance

What is it?
APNSwift is a non-blocking Swift module for talking to the Apple Push Notification service, built on AsyncHttpClient, with JWT authentication, separate development and production clients, Live Activity support and a swift-service-lifecycle Service conformance so the client shuts down with your app. The install line in the README points at 8.0.0 while the newest tag is a beta.
Who is it for?
Adopt APNSwift when you run a Swift server and want to send Apple push notifications, Live Activity updates and broadcast messages without hand rolling HTTP/2, JWT refresh and APNS error decoding. Do not adopt it for an architecture that is not Swift, and read the README's own note that a device token only works against the environment the app was built for, because that is the mistake that produces silent failures.
Can I use it commercially?
Yes. Apache-2.0 is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 7 days ago.
What is it written in?
Mainly Swift, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

A push client that does not block your event loop

APNSwift is described as a non-blocking Swift module for sending remote Apple Push Notification requests to APNS, built on AsyncHttpClient. The repository description broadens the target platforms to iOS, iPadOS, tvOS, macOS, watchOS, visionOS and Safari, which is the full range of Apple platforms that accept push.

The design goal is stated in the Getting Started section as providing semantically correct structures to sending push notifications, and the module is written for the Swift server side rather than for the device side. The topics list the underlying pieces, with swift-nio and swift-nio-http2 alongside swift and apns-http2, and the README carries the badge for a package that has graduated the Swift Server Workgroup incubation process, which is a signal about how the project is reviewed rather than about its features.

What the library takes off your hands is the transport. APNS speaks HTTP/2 with a persistent connection per device token, authenticates with a token that expires, and answers with a status and a per-device error body when it refuses a notification. Doing that by hand means maintaining the connection, refreshing the credential, and mapping Apple's error reasons onto your own error type. APNSwift wraps all of that in types, so a failure arrives as a Swift error rather than as a status code you have to interpret.

The licence is Apache-2.0, the package is distributed with Swift Package Manager, and the last push to the main branch was on 2026-10-16.

JWT authentication and the two environments

Setting up a client is one call, and the shape of the configuration tells you what the library expects.

swift
let client = APNSClient(
    configuration: .init(
        authenticationMethod: .jwt(
            privateKey: try .init(pemRepresentation: privateKey),
            keyIdentifier: keyIdentifier,
            teamIdentifier: teamIdentifier
        ),
        environment: .development
    ),
    responseDecoder: JSONDecoder(),
    requestEncoder: JSONEncoder()
)

// Shutdown the client when done
try await client.shutdown()

The authentication method is a JWT built from three things: the private key, read from its PEM representation, the key identifier, and the team identifier. The environment is either .development or .production, and the README is explicit about why that choice matters.

A device token only works against the environment the app was built for, so most servers need a production client and a development client. The example above uses .development, and the rest of the document shows how to hold both.

The decoder and encoder are injectable, which is a small but practical detail. Passing your own JSONEncoder means your custom payload types can use your project's date encoding strategy, and passing your own JSONDecoder means the error responses are decoded the way your service already decodes JSON.

The client is a Service, so it shuts down with your app

This is the part of the design that has changed in the 8.x line, and it is the change that matters most for anyone running a modern Swift server.

With the ServiceLifecycleSupport trait, which the README says is enabled by default, APNSClient, APNSBroadcastClient and APNSClients conform to the Service protocol of swift-service-lifecycle. Add a client to your ServiceGroup and it is shut down when the group shuts down, so you never call shutdown() yourself. The README says this is how you use APNSwift from Hummingbird, Vapor 5, or any other ServiceGroup based app.

The Hummingbird example shows the shape:

swift
import APNS
import Hummingbird

let router = Router()
router.post("devices/:token/notify") { request, context in
    let token = try context.parameters.require("token")
    try await client.sendAlertNotification(notification, deviceToken: token)
    return HTTPResponse.Status.accepted
}

var app = Application(router: router)
app.addServices(client)
try await app.runService()

And Vapor 5 is the same idea with a different spelling:

swift
import APNS
import Vapor

let app = try Application()
app.post("devices", ":token", "notify") { req in
    let token = try req.parameters.require("token")
    try await client.sendAlertNotification(notification, deviceToken: token)
    return HTTPResponse.Status.accepted
}

app.addService(client)
try await app.start()

If you do not want the dependency, the trait can be turned off and you manage the lifetime yourself:

swift
.package(url: "https://github.com/kylebrowning/APNSwift.git", from: "8.0.0", traits: []),

and then the client is registered with a ServiceGroup of your own, with graceful shutdown signals and a logger:

swift
let serviceGroup = ServiceGroup(
    services: [server, client],
    gracefulShutdownSignals: [.sigterm],
    logger: logger
)
try await serviceGroup.run()

The trait is a package-level dependency choice rather than a per-client setting, so the decision is made when you add the dependency, not when you build the client.

APNSClients: one key, two environments

Because a token only works against the environment the app was built for, the common case is two clients. APNSClients is the type that holds them.

swift
let apns = APNSClients()
apns.configure(.jwt(
    privateKey: try .init(pemRepresentation: privateKey),
    keyIdentifier: keyIdentifier,
    teamIdentifier: teamIdentifier
))

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

One configure call registers the credential for both environments, and the client lookup is by ID, so the call site names the environment it needs. For custom setups, use(_:as:) registers a client under any ID, and default(to:) picks which one the client property returns.

APNSClients is itself a Service, so the README advises adding it to your group rather than the individual clients, which keeps the shutdown story in one place.

The other type in that family is APNSBroadcastClient, which appears in the trait conformance list, and the repository carries a separate document, BROADCAST_CHANNELS.md, for that feature. The fact that broadcast has its own document is a useful hint about where the less common parts of the API live.

Notifications are typed values, payloads can be empty

Sending a notification means constructing a typed value, and the payload requirement is looser than it first looks.

The README states that all notifications require a payload, but that the payload can be empty, and that it only needs to conform to Encodable. So the minimal payload is an empty struct:

swift
struct Payload: Codable {}

try await client.sendAlertNotification(
    .init(
        alert: .init(
            title: .raw("Simple Alert"),
            subtitle: .raw("Subtitle"),
            body: .raw("Body"),
            launchImage: nil
        ),
        expiration: .immediately,
        priority: .immediately,
        topic: "com.app.bundle",
        payload: Payload()
    ),
    deviceToken: "device-token"
)

The alert carries a title, a subtitle, a body and an optional launch image, each expressed as .raw values rather than plain strings, which leaves room for the localisable or attributed forms APNS supports. expiration and priority are enums with an .immediately case, and topic is the bundle identifier the notification is addressed to.

Live Activities have their own entry point, sendStartLiveActivityNotification, and the README lists two requirements for it to succeed. The Attributes and ContentState must match the live activity attributes provided in attributesType, and the alert must contain a title, a body and a sound, with the title and body visible on the device's paired Apple Watch when the activity starts.

The last point is a detail that only appears in the documentation because somebody hit it: the watch sees the title and body at start time, so an alert without them produces a live activity that starts on the phone and shows nothing on the wrist.

Versions, packaging and the beta you are pointed at

The install instructions say to add the package as a dependency in your Package.swift:

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

The release history shows version 7.0.2 on 2026-10-07, version 7.1.0 on 2026-10-16 and version 8.0.0-beta1, also on 2026-10-16. So the version the README asks for is a beta, while a stable 7.1.0 was published the same day. That is worth a deliberate decision rather than a default: a beta means the API may still move, and the from: "8.0.0" range will resolve to the beta.

The repository is set up for a Swift project with a Package.swift at the root, the sources in Sources and the tests in Tests, plus an ExampleApp directory, a Dockerfile, and the usual governance files including CONTRIBUTING.md, CONTRIBUTORS.md, CODE_OF_CONDUCT.md, SECURITY.md, SUPPORT.md and RELEASING.md. The release process has its own document, which usually means the versioning scheme is not the simplest possible one.

The Dockerfile is for building and testing the library in a container, not for shipping it:

dockerfile
FROM swift:6.2-bookworm

WORKDIR /code
RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true && apt-get -q update && \
    apt-get -q install -y \
    openssl libssl-dev

It installs the OpenSSL development libraries, which the HTTP/2 stack needs, resolves the package, copies the sources and tests, and runs swift build. The Swift 6.2 base image is the practical floor for working on the library itself.

Editorial conclusion

Adopt APNSwift when you run a Swift server and want to send Apple push notifications, Live Activity updates and broadcast messages without hand rolling HTTP/2, JWT refresh and APNS error decoding. Do not adopt it for an architecture that is not Swift, and read the README's own note that a device token only works against the environment the app was built for, because that is the mistake that produces silent failures. Before you pin a version, check whether you want the 8.0.0 beta the install line names or the 7.1.0 release, and read BROADCAST_CHANNELS.md, since broadcast support is a separate document from the main usage guide.

Frequently asked questions

How do I add APNSwift to a Swift package?

Add the package as a dependency in your Package.swift, with .package(url: "https://github.com/kylebrowning/APNSwift.git", from: "8.0.0"). The README notes that the newest tag at the time of writing is 8.0.0-beta1, so pin deliberately.

How do I send a push notification with APNSwift?

Create an APNSClient with a .jwt authentication method holding your private key, key identifier and team identifier, plus an environment, then call sendAlertNotification with an APNSAlertNotification that carries an alert, expiration, priority, topic and a payload conforming to Encodable.

Does APNSwift support both the development and production environments?

Yes, and it warns that a device token only works against the environment the app was built for. APNSClients holds clients looked up by ID, configure(.jwt(...)) registers the credential for both, and you call apns.client(.development) or apns.client(.production).

How do I use APNSwift with Hummingbird or Vapor 5?

The ServiceLifecycleSupport trait is enabled by default, so the client conforms to swift-service-lifecycle's Service and is shut down with your group. In Hummingbird you call app.addServices(client) and in Vapor app.addService(client), and you never call shutdown() yourself.

Official sources

  1. Issues
  2. kylebrowning/APNSwift on GitHub
  3. License: Apache-2.0
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/kylebrowning-apnswift.svg)](https://hysenlabs.com/projects/kylebrowning-apnswift)

Was this useful?