Push notifications are the primary channel for re-engaging users and delivering time-sensitive information outside your app. On iOS, every remote notification flows through the Apple Push Notification service (APNs) β Appleβs infrastructure that relays messages from your server to the userβs device.
This guide covers the complete lifecycle: configuring credentials in the developer portal, registering for device tokens, handling permissions, constructing payloads, implementing rich-media extensions, managing Live Activities, and deploying to production.
Push notifications involve two separate phases: registration (app β server) and delivery (server β APNs β device).
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β REGISTRATION FLOW β
β β
β ββββββββββββ registerForRemote() ββββββββ device token β
β β Your β βββββββββββββββββββββββΊ β APNs β βββββββββββββββββββΊ β
β β App β βββββββββββββββββββββββ β β β
β ββββββββββββ didRegisterWithToken ββββββββ β
β β β
β β POST /register-device (token + user_id) β
β βΌ β
β ββββββββββββ β
β β Your β stores token in database β
β β Server β β
β ββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β DELIVERY FLOW β
β β
β ββββββββββββ HTTP/2 POST /3/device/<token> ββββββββ β
β β Your β ββββββ JWT + JSON payload βββββββΊ β APNs β β
β β Server β ββββββββ 200 (accepted) βββββββββ β β β
β ββββββββββββ ββββββββ β
β β β
β persistent TLS β connection β
β βΌ β
β ββββββββββββ ββββββββββββ β
β β Your β ββββββ notification data βββββ β Device β β
β β App β β (iOS) β β
β ββββββββββββ ββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββThe numbered steps:
registerForRemoteNotifications().POST /register-device).APNs is reliable but not transactional β it stores at most one undelivered notification per device token. Never assume delivery, and plan for it idempotently.
.apns file drag-and-drop on Apple Silicon Macs with Xcode 14+).In the Apple Developer Portal, navigate to Certificates, Identifiers & Profiles > Identifiers. Create or select an App ID matching your appβs bundle identifier. Enable the Push Notifications capability.
Apple recommends token-based authentication (.p8) for all new projects. It never expires, works across all apps in your account, and serves both sandbox and production environments.
Go to Keys in the portal and click +.
Name the key (e.g., βAPNs Production Keyβ) and check Apple Push Notifications service (APNs).
Click Register, then Download the .p8 file.
Apple only lets you download this file once. Store it securely β never commit it to version control.
Note the Key ID (10-character alphanumeric string) and your Team ID (visible in Membership details).
If you maintain legacy infrastructure, you can use certificate-based authentication (.p12) instead. These expire annually and require separate certificates for development and production.
aps-environment entitlement to your App ID and provisioning profile.Before you can show alerts or badges, the user must explicitly grant
permission. Use the UserNotifications framework.
The async/await API shown below requires iOS 17+. On earlier versions, use
the completion-handler variant (requestAuthorization(options:completionHandler:)).
import UserNotifications
let center = UNUserNotificationCenter.current()
do {
let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge])
if granted {
UIApplication.shared.registerForRemoteNotifications()
}
} catch {
print("Permission error: \(error)")
}Call registerForRemoteNotifications() after the user grants permission.
This triggers the APNs registration that produces a device token.
Provisional authorization delivers notifications silently to Notification Center without a prompt. Users can later opt-in to alerts via a βKeepβ button on the notification.
let granted = try await center.requestAuthorization(
options: [.alert, .sound, .badge, .provisional]
)This is useful for apps that want to demonstrate notification value before asking for full access. You can upgrade from provisional to full authorization later.
App Clips use .ephemeral to grant short-lived notification permission without
a persistent prompt.
Do not request permission on launch. Show a pre-permission screen explaining the value first, then ask after a meaningful action (e.g., completing a purchase, receiving a message).
Implement these methods in your AppDelegate to receive and forward the device
token.
import UIKit
import UserNotifications
@main
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
UNUserNotificationCenter.current().delegate = self
return true
}
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
let tokenString = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
forwardTokenToServer(tokenString)
}
func application(
_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error
) {
print("Failed to register: \(error.localizedDescription)")
}
private func forwardTokenToServer(_ token: String) {
// POST to your server endpoint
var request = URLRequest(url: URL(string: "https://api.example.com/devices")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body = ["deviceToken": token, "platform": "ios"]
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
URLSession.shared.dataTask(with: request).resume()
}
}Token management rules:
registerForRemoteNotifications() on every launch. APNs may issue a
new token after an OS update, restore from backup, or reinstall.410 Gone or 400 BadDeviceToken, immediately remove
that token from your database.Your server authenticates with APNs using one of two methods.
The server generates a JSON Web Token (JWT) signed with your .p8 private
key. The JWT has a maximum lifetime of one hour; the server should regenerate
it periodically.
JWT header:
{
"alg": "ES256",
"kid": "ABC123DEFG"
}JWT payload:
{
"iss": "TEAMID1234",
"iat": 1680000000
}The same .p8 key works for both sandbox and production β the server only
needs to switch the APNs endpoint URL.
A .p12 certificate is tied to a specific App ID and environment. Requires
separate certificates for development and production. Expires annually.
Always use token-based auth for new projects.
| Environment | Endpoint |
|---|---|
| Sandbox | https://api.sandbox.push.apple.com/3/device/ |
| Production | https://api.push.apple.com/3/device/ |
Use the sandbox endpoint for debug builds installed via Xcode. Use production for TestFlight and App Store builds.
The payload is a JSON dictionary with an aps key at the root.
{
"aps": {
"alert": {
"title": "New Message",
"body": "You have a new message from Alex."
},
"badge": 3,
"sound": "default"
},
"customData": {
"conversationId": "abc123"
}
}| Header | Value |
|---|---|
apns-topic |
Your appβs bundle ID |
apns-push-type |
alert, background, liveactivity, voip, complication |
apns-priority |
10 (immediate) or 5 (power-friendly). 10 must trigger an alert, sound, or badge β it is an error to use 10 for a silent push (content-available only) |
apns-expiration |
Unix epoch seconds; 0 for immediate expiration |
authorization |
bearer <JWT> |
No visible UI β wakes the app to fetch data in the background.
{
"aps": {
"content-available": 1
}
}Silent pushes are aggressively rate-limited by iOS. Use them sparingly β
consider BackgroundTasks framework for periodic updates.
content-available: 1. Wakes app to refresh
data.Implement UNUserNotificationCenterDelegate to control notification
presentation and handle taps. The async delegate methods below require
iOS 17+ β use the completion-handler variants for earlier targets.
extension AppDelegate: UNUserNotificationCenterDelegate {
// Called when a notification arrives while the app is in the foreground.
// Return the presentation options you want (default is none).
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification
) async -> UNNotificationPresentationOptions {
return [.banner, .sound, .badge]
}
// Called when the user taps or responds to a notification.
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse
) async {
let userInfo = response.notification.request.content.userInfo
handleNotification(userInfo)
}
private func handleNotification(_ userInfo: [AnyHashable: Any]) {
guard let conversationId = userInfo["conversationId"] as? String else { return }
// Navigate to the relevant screen
}
}Set the delegate early, before the app finishes launching, to avoid missing notifications:
UNUserNotificationCenter.current().delegate = selfA Notification Service Extension intercepts remote notifications before they are displayed, giving you up to 30 seconds to modify the payload β download images, decrypt content, or localize text.
NotificationService). Xcode generates a
NotificationService.swift file.The extension triggers only when the payload includes "mutable-content": 1.
import UserNotifications
class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent?
override func didReceive(
_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
) {
self.contentHandler = contentHandler
bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent
guard let bestAttemptContent else {
contentHandler(request.content)
return
}
// Download and attach an image
guard let urlString = request.content.userInfo["image_url"] as? String,
let url = URL(string: urlString) else {
contentHandler(bestAttemptContent)
return
}
let task = URLSession.shared.downloadTask(with: url) { localURL, _, error in
guard let localURL, error == nil else {
contentHandler(bestAttemptContent)
return
}
let ext = url.pathExtension.isEmpty ? "jpg" : url.pathExtension
let savedURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString + "." + ext)
do {
try FileManager.default.moveItem(at: localURL, to: savedURL)
let attachment = try UNNotificationAttachment(
identifier: "image",
url: savedURL,
options: nil
)
bestAttemptContent.attachments = [attachment]
} catch {
// Deliver without attachment
}
contentHandler(bestAttemptContent)
}
task.resume()
}
override func serviceExtensionTimeWillExpire() {
// Called when the 30-second budget is nearly exhausted.
if let contentHandler, let bestAttemptContent {
contentHandler(bestAttemptContent)
}
}
}{
"aps": {
"alert": {
"title": "Special Offer",
"body": "Check out our latest deals!"
},
"mutable-content": 1
},
"image_url": "https://example.com/promo.jpg"
}UNNotificationAttachment supports images, GIFs, video, and audio. The file
extension determines the inferred media type β CDN URLs with query strings
often need explicit extension handling.
com.app.Main β com.app.Main.NotificationService).mutable-content key is required β without it the extension never
runs.content-available: 1 without an alert dictionary) will
not trigger the extension.contentHandler β if you donβt, the system displays the
original payload after a timeout.Define interactive notification buttons using UNNotificationCategory and
register them at launch.
// Register categories early in didFinishLaunching
let acceptAction = UNNotificationAction(
identifier: "ACCEPT",
title: "Accept",
options: .foreground
)
let declineAction = UNNotificationAction(
identifier: "DECLINE",
title: "Decline",
options: .destructive
)
let category = UNNotificationCategory(
identifier: "FRIEND_REQUEST",
actions: [acceptAction, declineAction],
intentIdentifiers: [],
options: .customDismissAction
)
UNUserNotificationCenter.current().setNotificationCategories([category])Server sends the category key in the aps payload:
{
"aps": {
"alert": { "title": "Friend Request", "body": "Alice wants to be your friend" },
"category": "FRIEND_REQUEST"
}
}Handle the response in the delegate:
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse
) async {
switch response.actionIdentifier {
case "ACCEPT":
// Accept friend request
case "DECLINE":
// Decline
case UNNotificationDefaultActionIdentifier:
// User tapped the notification body
default:
break
}
}Live Activities are persistent, glanceable widgets on the Lock Screen and Dynamic Island that update in place for ongoing events. They are not push notifications, but can be updated via APNs.
ActivityAttributes struct with a nested ContentState.ActivityConfiguration.Activity.request(...).To update a Live Activity from your server, use the liveactivity push type:
apns-push-type: liveactivity
apns-topic: <bundle-id>.push-type.liveactivityWith iOS 18, you can send a single push notification to update thousands of Live Activities at once via a channel. No need to store individual push tokens.
Channel: Your Server ββsingle POSTβββΊ APNs βββΊ All Subscribed DevicesUse the Push Notifications Console to test integration.
If you started with provisional authorization, you can request full authorization later β the system shows the prompt again.
func upgradeToFullAuthorization() async -> Bool {
let center = UNUserNotificationCenter.current()
let settings = await center.notificationSettings()
guard settings.authorizationStatus == .provisional else {
return settings.authorizationStatus == .authorized
}
do {
let granted = try await center.requestAuthorization(
options: [.alert, .sound, .badge]
)
return granted
} catch {
return false
}
}iOS 18 introduced Apple Intelligence-powered Priority Notifications that surface important alerts at the top of the Notification Center. There is no opt-in mechanism for apps β the system determines priority based on content.
iOS 18 reduced Live Activity update frequency from every 1 second to every 5β15 seconds. Most use cases (delivery, ride-sharing, sports scores) are unaffected. Second-by-second use cases (fitness tracking, stock tickers) need alternative approaches.
As announced at WWDC 2024, broadcast push notifications let you update all Live Activities for an event with a single APNs request. Manage channel lifecycles via the channel management API β delete channels when events end.
On Apple Silicon Macs running Xcode 14+, create a .apns file and drag it onto
the simulator:
{
"Simulator Target Bundle": "com.example.app",
"aps": {
"alert": { "title": "Test", "body": "Hello from simulator" },
"sound": "default"
}
}xcrun simctl push booted com.example.app payload.jsonGenerate a JWT signed with your .p8 key first (it expires after one hour).
Use openssl with base64 URL-safe encoding:
# Set your values
AUTH_KEY_ID="ABC123DEFG"
TEAM_ID="TEAMID1234"
AUTH_KEY="/path/to/AuthKey_ABC123DEFG.p8"
# Build JWT components
HEADER=$(printf '{ "alg": "ES256", "kid": "%s" }' "$AUTH_KEY_ID" \
| openssl base64 -e -A | tr '+/' '-_' | tr -d =)
CLAIMS=$(printf '{ "iss": "%s", "iat": %d }' "$TEAM_ID" "$(date +%s)" \
| openssl base64 -e -A | tr '+/' '-_' | tr -d =)
JWT_HEADER_CLAIMS="${HEADER}.${CLAIMS}"
SIGNATURE=$(printf "$JWT_HEADER_CLAIMS" \
| openssl dgst -binary -sha256 -sign "$AUTH_KEY" \
| openssl base64 -e -A | tr '+/' '-_' | tr -d =)
JWT_TOKEN="${HEADER}.${CLAIMS}.${SIGNATURE}"Then send the push:
curl -v --header "apns-topic: com.example.app" \
--header "apns-push-type: alert" \
--header "authorization: bearer $JWT_TOKEN" \
--data '{"aps":{"alert":"Test"}}' \
--http2 \
https://api.sandbox.push.apple.com/3/device/$DEVICE_TOKENAppleβs Push Notifications Console lets you compose and send test pushes without writing backend code.
| Symptom | Likely Cause | Fix |
|---|---|---|
| Notification never arrives | Wrong APNs endpoint (sandbox vs production) | Match endpoint to build type |
BadDeviceToken (400) |
Token from wrong environment or expired | Re-register on launch; verify endpoint match |
TopicDisallowed (400) |
Bundle ID mismatch | Verify apns-topic matches App ID |
ExpiredProviderToken (403) |
JWT timestamp older than 1 hour | Refresh JWT periodically |
PayloadTooLarge (400) |
Payload exceeds 4 KB | Compress or use mutable-content to fetch large data |
| No token generated | Missing capability or provisioning | Re-check Push Notifications entitlement |
| Extension not triggered | Missing mutable-content: 1 |
Add the flag to the payload |
| Background push ignored | No Remote notifications background mode |
Enable Background Modes > Remote notifications |
apns-priority: 5 for non-urgent notifications to preserve battery
and avoid throttling.410 Gone tokens from your database
immediately.didFinishLaunchingWithOptions returns,
to catch notifications the app might receive at launch.A production-ready notification flow:
.notDetermined, show pre-permission UI..provisional or
.alert/.sound/.badge.mutable-content: 1) downloads media.
Delegate handles foreground presentation and tap navigation.registerForRemoteNotifications() on every launch;
server updates tokens when they change.410, 400) to
clean up stale tokens.