better-push
Delivery channels

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.

  1. In the Apple Developer portal, open Certificates, Identifiers & Profiles → Keys and create a key with Apple Push Notifications service (APNs) enabled.
  2. Download the .p8 file. Apple lets you download it once; if you lose it, revoke the key and create a new one.
  3. 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

src/push.ts
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
});
OptionMeaning
keyIdKey ID of the p8 auth key. Signed into the JWT header as kid.
teamIdApple developer team id. The JWT's iss claim.
keyThe contents of the .p8 file, a PKCS#8 PEM. Not a path.
topicThe app bundle id, sent as the apns-topic header.
productiontrue for the production gateway. Default false (sandbox).
ttlOptional seconds APNs stores the push for an offline device.
hostFull 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:

productionGatewayTokens that work
false (default)api.sandbox.push.apple.comDevelopment and debug builds
trueapi.push.apple.comTestFlight 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> with authorization: bearer <jwt>, apns-topic, apns-push-type: alert, apns-priority: 10, and apns-expiration (0 unless you set ttl).
  • Body. { aps: { alert: { title, body }, "mutable-content": 1 } } plus your notification data alongside it. A key named aps inside data is 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.

On this page