APNs setup (iOS)
To deliver to iOS, OpenPush needs an Apple Push Notification service credential. It uses p8 provider token authentication only — an authentication key you download once from Apple, plus the three identifiers that describe it. There is no certificate path anywhere in the server.
When to use
Do this once per app before any iOS device can receive a push, and again whenever you rotate the key or change bundle identifier. Android and web delivery use a separate credential — see platform-setup-fcm.md.
Prerequisites
- An Apple Developer account with access to Certificates, Identifiers & Profiles
- The app's bundle identifier
- Your OpenPush app and console access with the
managerrole or higher
What you need from Apple
Create an APNs authentication key in the Apple Developer portal and download the .p8 file. Apple lets you download it exactly once, so store it somewhere you can retrieve it.
You then have four pieces of configuration:
| Field | Where it comes from | Notes |
|---|---|---|
The .p8 key file | Downloaded once when you create the key | Encrypted at rest by OpenPush and bound to this app; it is never returned by any API |
key_id | The key's 10-character identifier in the portal | Also appears in the downloaded filename |
team_id | Your Apple Developer team identifier | Top right of the developer portal |
bundle_id | Your app's bundle identifier | Must match the app that produced the device tokens |
sandbox | Your choice | Whether the app-level default is Apple's sandbox host |
Step 1 — upload the key
Uploading a platform credential is a console action: open your app's Settings → Platforms → iOS, paste or upload the .p8, and fill in the key id, team id, bundle id and the sandbox checkbox. There is no /v1 route for uploading platform credentials.
The key must be a PKCS#8 elliptic-curve P-256 (secp256r1) private key, which is what Apple issues. An RSA key or a key on another curve is rejected at upload with a named error rather than being accepted and failing later at send time.
The .p8 itself is encrypted at rest and cryptographically bound to this app and the iOS platform, so a credential row cannot be replayed against a different app.
Step 2 — preflight
Saving the credential runs a preflight check, and that check is real: OpenPush signs an actual JWT with the stored key rather than merely confirming that fields are present. A key that cannot sign — wrong type, wrong curve, corrupted paste — fails here instead of failing silently on your first campaign.
Verify: GET /v1/apps/{app_id}/settings reports iOS as ready under platforms.
Code
The platform-wide answer — whether any APNs credential is configured, and where it came from — is on GET /healthz as apns and apns_source. Per-app readiness is the settings response above.
How the platform-wide fallback behaves
There is one more place an APNs credential can come from, and it is worth understanding even though it is not yours to set. OpenPush can hold a single platform-wide APNs credential, configured by the OpenPush team through the environment, which is used by any app that has not uploaded its own:
| Variable | Meaning |
|---|---|
APNS_P8 | The key contents |
APNS_P8_PATH | A path to the key file, as an alternative to APNS_P8 |
APNS_KEY_ID | The key identifier |
APNS_TEAM_ID | The team identifier |
APNS_BUNDLE_ID | The bundle identifier |
OP_APNS_SANDBOX | 1 to default the platform to Apple's sandbox host |
Your app's own credential always takes precedence, and uploading one is the supported path — the platform-wide fallback has no notion of which app a token belongs to, so it is not something to rely on. GET /healthz reports apns_source, which is how you tell which of the two answered.
Sandbox and production
Apple runs two hosts, and a device token issued by one is meaningless to the other:
| Setting | Host |
|---|---|
| Production | https://api.push.apple.com |
| Sandbox | https://api.sandbox.push.apple.com |
OpenPush resolves the host per device, not per app:
- If the device reported a
sandboxvalue at registration, that wins. - Otherwise the app-level checkbox decides.
This matters more than it sounds. A device registered from a debug build reports sandbox; a device registered from a TestFlight or App Store build reports production. Because the device's own answer wins, a TestFlight build and an App Store build can coexist in one OpenPush app without splitting them across two apps or flipping a global switch between test sends.
If the SDK never says — the field is absent at registration — the device keeps a null sandbox value and the app-level checkbox continues to decide for it, exactly as before.
How a device is routed to Apple
A subscription is sent to APNs only when all three of these hold:
- The subscription's platform is
ios. - The token looks like an APNs device token — hexadecimal, even length, 64 to 200 characters.
- APNs is configured for the app.
Otherwise the device falls back to FCM. That fallback is deliberate: live iOS fleets migrating from OneSignal often carry FCM registration tokens rather than raw APNs tokens, and those must keep working. If your iOS devices are quietly going out through FCM, check the token shape before checking anything else.
What OpenPush sends to Apple
Headers set on every request:
| Header | Value |
|---|---|
apns-topic | Your bundle id, with .push-type.liveactivity appended for Live Activities |
apns-push-type | alert, background, or liveactivity — those three only |
apns-priority | 10 for a high-priority alert, 5 otherwise; a background push is forced to 5 even if you ask for high |
apns-expiration | An absolute epoch computed from the message TTL, defaulting to 24 hours; a TTL of 0 is honoured as "deliver now or discard" |
apns-collapse-id | The collapse key, truncated to 64 characters |
apns-id | A UUID derived deterministically from the message and token — Apple rejects OpenPush's own message id format |
voip, complication, fileprovider, mdm, location and pushtotalk push types are not supported.
Provider tokens are cached per app and key and re-signed every 50 minutes, so a campaign to a large fleet signs one JWT rather than one per device.
mutable-content: 1 is set on every alert push, not only on pushes carrying an image. That is what lets your Notification Service Extension run on backgrounded arrivals and post the confirmed receipt. See sdk-ios.md.
Error behavior
APNs responses are classified into three very different outcomes, and the difference matters when you are reading delivery stats:
| Apple's response | What OpenPush does |
|---|---|
410, or reason Unregistered | Marks that subscription status -10 — uninstalled or token expired |
400 with BadDeviceToken or DeviceTokenNotForTopic | Marks that subscription status -2 |
InvalidProviderToken, MissingProviderToken, ExpiredProviderToken, TooManyProviderTokenUpdates, BadCertificate, BadCertificateEnvironment, Forbidden, or any 401/403 | Treats it as a configuration fault: the fan-out stops immediately and zero subscriptions are touched |
429, 500, 503 | Records a retry on the delivery row only; the subscription is never changed |
| A transport error | Same — delivery row only |
| Anything else | Recorded on the delivery row with Apple's status and reason |
Two properties worth relying on:
- Only
-10and-2ever change a subscription's status. A bad key cannot mass-unsubscribe your audience, because configuration faults are checked before retry statuses and stop the send instead. A429 TooManyProviderTokenUpdatesis therefore never mistaken for a per-device retry. - A departure date is never moved forward. If a subscription was already marked unsubscribed, a later failure does not rewrite when it happened.
An expired provider token is re-signed once and retried on the same host before being reported.
Limits
- p8 only. There is no
.p12or certificate upload, and no plan expressed in the server for one. - Console-only upload. Platform credentials are not exposed on
/v1. - The private key is never readable back out of OpenPush — rotating means uploading a new key.
- Critical alerts are not supported: the critical sound form is never built and
interruption-level: criticalis deliberately excluded, because the entitlement is not something a general-purpose sender can assume. target-content-idis not supported.- Live Activity and per-platform iOS options are covered in sending-messages.md and live-activities.md.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Upload rejected with a key-type error | The .p8 is RSA or on the wrong curve, or the paste lost its PEM header/footer | Re-download the key from Apple and upload the file unmodified |
| Preflight fails but the fields look right | The key id or team id does not belong to that key | Re-check both in the developer portal; preflight signs a real JWT, so a mismatch shows up here |
| A whole send fails and no device is marked unreachable | A provider-token or certificate-environment fault | Expected and deliberate — fix the credential, then resend; nothing was marked bad |
Every token gets DeviceTokenNotForTopic | The bundle id does not match the app that issued the tokens | Correct the bundle id; the tokens themselves are fine |
| Devices work in debug but not in TestFlight | The app-level sandbox checkbox is being used because the SDK never reported a sandbox value | Update to an SDK version that reports it, or split builds; the device's own value always wins when present |
| iOS devices are being sent through FCM | The stored token is an FCM registration token, not an APNs device token | Expected for migrated fleets; re-register through the OpenPush iOS SDK to get a native token |
Subscriptions turning to -10 in bulk after a release | Genuine uninstalls, or tokens issued against the other Apple host | Check sandbox routing before assuming churn |
GET /healthz shows apns: false but sends work | The credential is per-app, not platform-wide | /healthz reports the platform-wide answer; check the app's settings for per-app readiness |
FAQ
Can I upload a .p12 certificate instead?
No. Token-based p8 authentication is the only path.
Does one key work for several apps? Yes, as far as Apple is concerned — an APNs auth key is team-wide. Upload it to each OpenPush app that needs it, each with that app's own bundle id.
How do I rotate a key? Create a new key in the Apple portal, upload it in the console, and confirm iOS readiness. Cached provider tokens are keyed by the key's fingerprint, so a new key takes effect immediately rather than after the 50-minute refresh.
What happens to in-flight sends if the key expires? The fan-out stops on the first configuration fault and no subscriptions are modified. Fix the credential and resend.
Is the sandbox flag per app or per device? Both exist; the device's value wins when it has one.
Related
- quickstart.md
- sdk-ios.md
- platform-setup-fcm.md
- users-and-subscriptions.md
- sending-messages.md
- live-activities.md
- security-and-limits.md
- ../api-handbook/01-apps-keys-settings.md