Skip to main content
Tutorials

iOS push notifications with APNs, from p8 key to your first push

This tutorial adds remote push notifications to an iOS app from scratch. When you’re done, you can run one command on your computer and see a notification appear on your iPhone. It’s for developers whose app already runs on an iPhone and who are adding push for the first time. Before building your own push service or integrating a third-party push platform, it’s worth getting the whole flow working once with this tutorial. You need an Apple Developer account, an iPhone and a USB cable, and a computer with KXBuild installed. If you haven’t installed it yet, follow the install page.

There are five steps.

  1. Enable Push Notifications for the App ID in the developer portal and regenerate the provisioning profiles.
  2. Add an entitlements file to the project with aps-environment.
  3. Request notification permission in code, register for push, and get the device token.
  4. Create an APNs authentication key (.p8) in the developer portal.
  5. Use the key to send a push from your computer to the phone.

Enable the Push Notifications capability

Sign in to developer.apple.com and open Certificates, Identifiers & Profiles. On the Identifiers page, open your app’s App ID, check Push Notifications in the Capabilities list, and click Save. The Configure button next to it is for legacy push certificates. You don’t need it when you use a .p8 key.

The App ID must be an explicit Bundle ID. Wildcard App IDs (com.example.*) can’t use push. If you don’t have an explicit App ID yet, register one by following Get an iOS certificate, p12 and provisioning profile on Windows.

Regenerate the provisioning profiles

A provisioning profile records the capabilities the App ID had when the profile was generated. Profiles generated before you enabled push don’t include it, so you have to regenerate them.

  1. On the Profiles page, open each profile for this App ID (Development, Ad Hoc, App Store), click Edit, click Save without changing anything, then Download. Do the same for profiles marked Invalid.
  2. Replace the old files in your project’s keychain folder with the new ones and delete the old ones. If old and new copies are both in the folder, the build may pick the old one without push.

Double-click a .mobileprovision in VS Code to view its contents. If Entitlements contains aps-environment, the profile includes push. The value is development in Development profiles and production in Ad Hoc and App Store profiles.

For profile errors such as doesn't include the Push Notifications capability, see 16 common iOS code signing and .ipa install errors, and how to fix them.

Add entitlements to the project

The app has to be signed with aps-environment to register for push. Write development in the file. At build time KXBuild takes the value from the provisioning profile it uses. Debug uses a development profile and gets development, Release uses an App Store or Ad Hoc profile and gets production, so one file covers both debugging and release.

Create MyApp.entitlements in the app’s source folder with the content below. This file doesn’t need to be added to the target. To change values or add other capabilities later, double-click it and edit it in the Property List Editor.

1<?xml version="1.0" encoding="UTF-8"?>
2<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3<plist version="1.0">
4<dict>
5	<key>aps-environment</key>
6	<string>development</string>
7</dict>
8</plist>

Then point the target to this file. Run the command below in the project folder. The path is relative to the folder containing the .xcodeproj.

1kxbuild project add-setting -k CODE_SIGN_ENTITLEMENTS -v MyApp/MyApp.entitlements -t MyApp -P .\MyApp.xcodeproj

After that, you can find it by searching for CODE_SIGN_ENTITLEMENTS on the Build Settings page of the Project Settings Editor. If your project already has an entitlements file (for example because it uses App Groups), add aps-environment to that file instead of creating a new one.

Request permission and get the device token

Remote push needs two things. You ask the user for notification permission, which decides whether notifications can be shown. And you register with APNs to get the device token for this app on this phone, which your backend uses to send pushes to the device.

In a SwiftUI project, hook up an AppDelegate with @UIApplicationDelegateAdaptor. Replace your app’s entry file (for example MyAppApp.swift) with the code below, renaming MyAppApp and ContentView to match your project.

 1import SwiftUI
 2import UserNotifications
 3
 4final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
 5    func application(_ application: UIApplication,
 6                     didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
 7        let center = UNUserNotificationCenter.current()
 8        center.delegate = self
 9        center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
10            print("Notification permission granted=\(granted) error=\(String(describing: error))")
11        }
12        application.registerForRemoteNotifications()
13        return true
14    }
15
16    func application(_ application: UIApplication,
17                     didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
18        let token = deviceToken.map { String(format: "%02x", $0) }.joined()
19        print("Device token: \(token)")
20    }
21
22    func application(_ application: UIApplication,
23                     didFailToRegisterForRemoteNotificationsWithError error: Error) {
24        print("Failed to register for push: \(error)")
25    }
26
27    // Show banners while the app is in the foreground too
28    func userNotificationCenter(_ center: UNUserNotificationCenter,
29                                willPresent notification: UNNotification,
30                                withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
31        completionHandler([.banner, .list, .sound, .badge])
32    }
33
34    // The user tapped a notification
35    func userNotificationCenter(_ center: UNUserNotificationCenter,
36                                didReceive response: UNNotificationResponse,
37                                withCompletionHandler completionHandler: @escaping () -> Void) {
38        print("Opened notification: \(response.notification.request.content.userInfo)")
39        completionHandler()
40    }
41}
42
43@main
44struct MyAppApp: App {
45    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
46
47    var body: some Scene {
48        WindowGroup {
49            ContentView()
50        }
51    }
52}

registerForRemoteNotifications() doesn’t depend on whether the user allows notifications. You get a device token even if they decline, but notifications won’t be shown. In a production app you can ask for permission later, for example when the user turns on reminders, and far more people will allow it. By default, pushes that arrive while the app is in the foreground aren’t shown. Returning .banner from willPresent makes them show as a banner.

Press F5 with the Debug configuration to run the app on your phone. See Debug iOS apps on Windows with LLDB, breakpoints and crash logs for debugging. On first launch a notification permission dialog appears. Tap Allow. In Output > KXBuild Console, find the line starting with Device token: and copy the 64-character hex string after it. You need it in a later step.

Device tokens are tied to an environment. A Debug build gets a development (sandbox) token, which only works with the development endpoint. The token can change after the app is deleted and reinstalled, on a new phone, or after restoring the system, so a production app should send the latest token to your backend on every launch.

Create an APNs authentication key

To send pushes through APNs, you have to prove you belong to the developer team. Apple offers two kinds of credentials, a .p8 authentication key and a .p12 push certificate. A .p8 key doesn’t expire, one key can send pushes to every app in the team, and it works in both development and production, so use it for new projects. Push certificates have to be renewed every year and you need one per app per environment. You only need them to connect to older systems that support nothing else.

  1. On the Keys page of Certificates, Identifiers & Profiles, click “+”, enter a name and check Apple Push Notifications service (APNs).
  2. Click Configure next to it, set the environment to Sandbox & Production and the scope to Team Scoped (All Topics), save, then click Continue and Register.
  3. Click Download to get AuthKey_XXXXXXXXXX.p8. You can only download this file once. If you lose it you have to revoke it and create a new one, so back it up right away.

Also note two values. The Key ID is the 10-character string on the key’s detail page, and it’s also in the file name. The Team ID is under Membership details in the account menu at the top right of the developer website, also 10 characters.

A .p8 key grants push access for your whole team. Don’t commit it to your repository and don’t ship it in your app.

Send a test push from your computer

APNs is an HTTP/2 API. Each request carries a JWT signed with the .p8 key (ES256), plus the target app’s Bundle ID and the device token.

Environment Endpoint Used for
Development https://api.sandbox.push.apple.com Debug builds, the ones installed with F5
Production https://api.push.apple.com TestFlight, App Store and Ad Hoc builds

Prepare the send script

The Node.js script below has no third-party dependencies. It can generate a JWT and send a push directly. Create a new folder, put the .p8 file in it, create apns.mjs, and change the constants at the top.

 1import crypto from 'node:crypto'
 2import fs from 'node:fs'
 3import http2 from 'node:http2'
 4
 5const TEAM_ID = 'ABCDE12345'                 // Team ID
 6const KEY_ID = 'XYZ1234567'                  // Key ID
 7const KEY_FILE = 'AuthKey_XYZ1234567.p8'     // .p8 file
 8const BUNDLE_ID = 'com.yourname.myapp'       // Your app's Bundle ID
 9const HOST = 'https://api.sandbox.push.apple.com' // For production, change to https://api.push.apple.com
10
11function makeJwt() {
12  const b64 = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url')
13  const unsigned = b64({ alg: 'ES256', kid: KEY_ID }) + '.' + b64({ iss: TEAM_ID, iat: Math.floor(Date.now() / 1000) })
14  const key = crypto.createPrivateKey(fs.readFileSync(KEY_FILE))
15  const sig = crypto.sign('sha256', Buffer.from(unsigned), { key, dsaEncoding: 'ieee-p1363' })
16  return unsigned + '.' + sig.toString('base64url')
17}
18
19const [cmd, deviceToken] = process.argv.slice(2)
20if (cmd === 'jwt') {
21  console.log(makeJwt())
22} else if (cmd === 'send' && deviceToken) {
23  const payload = fs.readFileSync('payload.json')
24  const client = http2.connect(HOST)
25  client.on('error', (err) => { console.error(err.message); process.exit(1) })
26  const req = client.request({
27    ':method': 'POST',
28    ':path': '/3/device/' + deviceToken,
29    authorization: 'bearer ' + makeJwt(),
30    'apns-topic': BUNDLE_ID,
31    'apns-push-type': 'alert',
32    'apns-priority': '10',
33  })
34  let status
35  let body = ''
36  req.on('response', (headers) => { status = headers[':status'] })
37  req.setEncoding('utf8')
38  req.on('data', (chunk) => { body += chunk })
39  req.on('end', () => { console.log(status, body); client.close() })
40  req.end(payload)
41} else {
42  console.log('Usage: node apns.mjs jwt or node apns.mjs send <device token>')
43}

In the same folder, create payload.json with the notification content, saved as UTF-8.

1{"aps":{"alert":{"title":"Hello","body":"This is your first push"},"sound":"default","badge":1}}

aps is reserved by the system. alert holds the title and body, sound set to default plays the default sound, and badge is the number on the app icon. Put custom data outside aps, and read it from userInfo when the user opens the notification. The whole payload must be 4 KB or less.

The JWT signature must be the raw 64-byte format, which is what dsaEncoding: 'ieee-p1363' in the script does. This is the most common mistake when you implement it in another language.

Send the push

Use either of the two approaches below.

  • Send with the script. Run the command below in the script’s folder, replacing the device token with the string you copied earlier.
  • Send with curl. Generate the JWT with the script, then send the request with curl. This is handy for debugging on a server or in CI.

The command to send with the script.

1node apns.mjs send DEVICE_TOKEN

The command to send with curl.

1$jwt = node apns.mjs jwt
2curl.exe -v --http2 -H "authorization: bearer $jwt" -H "apns-topic: com.yourname.myapp" -H "apns-push-type: alert" -H "apns-priority: 10" --data-binary "@payload.json" https://api.sandbox.push.apple.com/3/device/DEVICE_TOKEN

curl must support HTTP/2. Run curl.exe -V and check that the Features line includes HTTP2. Neither the curl that ships with Windows nor the one bundled with Git supports it, and they fail with option --http2: the installed libcurl version does not support this. Use the Windows build from curl.se instead, or just send with the script. On macOS and Linux, replace curl.exe with curl and $jwt with $(node apns.mjs jwt). In PowerShell, don’t type curl, because it’s an alias for Invoke-WebRequest.

A 200 response means APNs accepted the push, and the notification appears on the lock screen within a few seconds. For any other status code, the reason in the response explains why. See the next section.

If you’d rather not write a script, you can also sign in to Apple’s CloudKit Console, enter the Bundle ID and device token on the Push Notifications page, and send a test push from the browser.

Troubleshooting missing pushes

First check whether the app got a device token, then the APNs response, and finally the phone.

No device token

If the Console shows Failed to register for push with the error no valid "aps-environment" entitlement string found for application, the app was signed without the push capability. Check three things. Whether Push Notifications is checked for the App ID. Whether the profiles in keychain were downloaded again after you enabled push, with the old ones all deleted. Whether CODE_SIGN_ENTITLEMENTS points to the right file.

If the build fails with doesn't include the aps-environment entitlement, the provisioning profile doesn’t have the push capability, so regenerate it. If installation fails with invalid entitlements, the aps-environment value usually doesn’t match the profile, for example a Debug configuration using a file that says production.

APNs returns an error

Response Cause and fix
400 BadDeviceToken The device token doesn’t match the environment. A Debug build’s token was sent to the production endpoint, or the other way around. The token may also have been copied incompletely
400 DeviceTokenNotForTopic apns-topic doesn’t match the app’s Bundle ID
403 InvalidProviderToken The Key ID or Team ID is wrong, the .p8 belongs to another team, or the key isn’t enabled for this environment
403 ExpiredProviderToken The JWT is more than an hour old, or your computer’s clock is off. Generate a new one and sync the system time
410 Unregistered The app has been deleted from the phone and the token is no longer valid
429 TooManyProviderTokenUpdates The JWT is being regenerated too often. In production, reuse one JWT for 20 to 60 minutes

200 but nothing on the phone

  • You tapped Don’t Allow on first launch. On the phone, go to Settings > Notifications, find the app and turn on Allow Notifications.
  • A Focus mode or Do Not Disturb is on, so the notification goes to Notification Center without a banner.
  • The app is in the foreground and your code doesn’t implement willPresent.
  • The phone is offline, or the network blocks Apple’s push ports. Try once on cellular data.

Works in Debug but not in TestFlight

TestFlight, App Store and Ad Hoc builds use the production environment, and their device tokens are production tokens. Check that Release uses an App Store or Ad Hoc profile, that your backend has switched to https://api.push.apple.com, and that it uses the new token reported by this build. If you chose Sandbox & Production when creating the .p8 key, you don’t need a different key.

Silent pushes (a payload with only content-available: 1, used to let the app fetch data in the background) also need remote-notification in the UIBackgroundModes array in Info.plist, with push type background and priority 5. The system decides whether to wake the app based on battery and usage, so delivery isn’t guaranteed for every push.