APNs setup
The p8 auth key, the topic, sandbox vs production, and direct HTTP/2 delivery.
The APNs provider delivers to iOS device tokens registered by a native app. It talks to Apple directly - there is no third-party SDK in the path - and authenticates with a p8 auth key rather than a certificate.
1. Create the p8 auth key
You need a paid Apple Developer account and an app bundle id.
- In the Apple Developer portal, open Certificates, Identifiers & Profiles → Keys and create a key with Apple Push Notifications service (APNs) enabled.
- Download the
.p8file. Apple lets you download it once; if you lose it, revoke the key and create a new one. - Note the Key ID shown next to the key, and your Team ID from the membership page.
One p8 key works for every app in the team, in both sandbox and production, and does not expire the way a push certificate does.
2. Configure the provider
import { apns } from "@better-push/core/providers/apns";
apns({
keyId: process.env.APNS_KEY_ID!, // the p8 key id
teamId: process.env.APNS_TEAM_ID!, // Apple developer team id
key: process.env.APNS_KEY!, // the PEM contents of the .p8 file
topic: process.env.APNS_BUNDLE_ID!, // the app bundle id
production: process.env.APNS_PRODUCTION === "true", // default false
});| Option | Meaning |
|---|---|
keyId | Key ID of the p8 auth key. Signed into the JWT header as kid. |
teamId | Apple developer team id. The JWT's iss claim. |
key | The contents of the .p8 file, a PKCS#8 PEM. Not a path. |
topic | The app bundle id, sent as the apns-topic header. |
production | true for the production gateway. Default false (sandbox). |
ttl | Optional seconds APNs stores the push for an offline device. |
host | Full gateway origin override. Only useful in tests. |
Missing or empty values throw INVALID_CONFIG at construction, so a
misconfigured deployment fails at boot rather than on the first send.
Putting the key in an environment variable
key is the literal PEM text, newlines included:
-----BEGIN PRIVATE KEY-----
MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQg...
-----END PRIVATE KEY-----Some hosting dashboards collapse newlines into a literal \n. If yours does,
restore them when reading the variable:
key: process.env.APNS_KEY!.replace(/\\n/g, "\n"),3. Sandbox vs production
APNs has two gateways, and a device token is only valid against one of them:
production | Gateway | Tokens that work |
|---|---|---|
false (default) | api.sandbox.push.apple.com | Development and debug builds |
true | api.push.apple.com | TestFlight and App Store builds |
A dev build's token only works against sandbox
Sending a development-build token to the production gateway (or the reverse)
returns 400 BadDeviceToken, which is indistinguishable from a genuinely
dead token - so the device gets disabled and every later send skips it until
it re-registers. When you test with an Expo dev client, leave production at
false.
The apns-topic must match the bundle id of the build that produced the token
too. A mismatch returns 400 DeviceTokenNotForTopic, which is mapped to
provider_error and deliberately does not disable the device - it is your
configuration that is wrong, not the token.
4. Get a device token
APNs tokens come from a native app: Notifications.getDevicePushTokenAsync() in
the Expo app returns the raw APNs token as a hex string, registered as
{ platform: "ios", provider: "apns", token }. See
Native / Mobile Testing.
validateToken strips whitespace and requires a hex string, so the bracketed
form some tooling prints (<a1b2 c3d4 ...>) is rejected at registration rather
than silently failing to deliver.
How the provider talks to Apple
better-push speaks APNs HTTP/2 directly, using Node's built-in node:http2
and jose for signing. There is no node-apn or Firebase in the path.
- Provider token. An ES256 JWT signed with your p8 key, with header
{ alg: "ES256", kid: keyId }and claims{ iss: teamId, iat }. Apple accepts a provider token for about 60 minutes and rate-limits token generation, so the signed token is cached and reused for ~50 minutes, then regenerated. - Request.
POST /3/device/<token>withauthorization: bearer <jwt>,apns-topic,apns-push-type: alert,apns-priority: 10, andapns-expiration(0unless you setttl). - Body.
{ aps: { alert: { title, body }, "mutable-content": 1 } }plus your notificationdataalongside it. A key namedapsinsidedatais dropped, because that name is reserved by Apple. - Connection. One HTTP/2 session per batch, with the batch multiplexed over it at up to 10 requests in flight, closed when the batch finishes.
Responses are mapped to normalized codes: 200 is ok, 410 and
400 BadDeviceToken mark the token dead, 429 is rate_limited, 413 is
payload_too_large, and 403 credential faults are provider_error. Full
table in Token Lifecycle.