--- name: axiom-push-notifications-ref description: Use when needing APNs HTTP/2 transport details, JWT authentication setup, payload key reference, UNUserNotificationCenter API, notification category/action registration, service extension lifecycle, local notification triggers, Live Activity push headers, or broadcast push format. Covers complete push notification API surface. license: MIT --- # Push Notifications API Reference Comprehensive API reference for APNs HTTP/2 transport, UserNotifications framework, and push-driven features including Live Activities and broadcast push. ## Quick Reference ```swift // AppDelegate — minimal remote notification setup class AppDelegate: NSObject, 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 token = deviceToken.map { String(format: "%02x", $0) }.joined() sendTokenToServer(token) } func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { print("Registration failed: \(error)") } // Show notifications when app is in foreground func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { return [.banner, .sound, .badge] } // Handle notification tap / action response func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { let userInfo = response.notification.request.content.userInfo // Route to appropriate screen based on userInfo } } ``` --- ## APNs Transport Reference ### Endpoints | Environment | Host | Port | |-------------|------|------| | Development | api.sandbox.push.apple.com | 443 or 2197 | | Production | api.push.apple.com | 443 or 2197 | ### Request Format ``` POST /3/device/{device_token} Host: api.push.apple.com Authorization: bearer {jwt_token} apns-topic: {bundle_id} apns-push-type: alert Content-Type: application/json ``` ### APNs Headers | Header | Required | Values | Notes | |--------|----------|--------|-------| | apns-push-type | Yes | alert, background, liveactivity, voip, complication, fileprovider, mdm, location | Must match payload content | | apns-topic | Yes | Bundle ID (or .push-type.liveactivity suffix) | Required for token-based auth | | apns-priority | No | 10 (immediate), 5 (power-conscious), 1 (low) | Default: 10 for alert, 5 for background | | apns-expiration | No | UNIX timestamp or 0 | 0 = deliver once, don't store | | apns-collapse-id | No | String ≤64 bytes | Replaces matching notification on device | | apns-id | No | UUID (lowercase) | Returned by APNs for tracking | | authorization | Token auth | bearer {JWT} | Not needed for certificate auth | | apns-unique-id | Response only | UUID | Use with Push Notifications Console delivery log | ### Response Codes | Status | Meaning | Common Cause | |--------|---------|--------------| | 200 | Success | | | 400 | Bad request | Malformed JSON, missing required header | | 403 | Forbidden | Expired JWT, wrong team/key, topic mismatch | | 404 | Not found | Invalid device token path | | 405 | Method not allowed | Not using POST | | 410 | Unregistered | Device token no longer active (app uninstalled) | | 413 | Payload too large | Exceeds 4KB (5KB for VoIP) | | 429 | Too many requests | Rate limited by APNs | | 500 | Internal server error | APNs issue, retry | | 503 | Service unavailable | APNs overloaded, retry with backoff | --- ## JWT Authentication Reference ### JWT Header ```json { "alg": "ES256", "kid": "{10-char Key ID}" } ``` ### JWT Claims ```json { "iss": "{10-char Team ID}", "iat": {unix_timestamp} } ``` ### Rules | Rule | Detail | |------|--------| | Algorithm | ES256 (P-256 curve) | | Signing key | APNs auth key (.p8 from developer portal) | | Token lifetime | Max 1 hour (403 ExpiredProviderToken if older) | | Refresh interval | Between 20 and 60 minutes | | Scope | One key works for all apps in team, both environments | ### Authorization Header Format ``` authorization: bearer eyAia2lkIjog... ``` --- ## Payload Reference ### `aps` Dictionary Keys | Key | Type | Purpose | Since | |-----|------|---------|-------| | alert | Dict/String | Alert content | iOS 10 | | badge | Number | App icon badge (0 removes) | iOS 10 | | sound | String/Dict | Audio playback | iOS 10 | | thread-id | String | Notification grouping | iOS 10 | | category | String | Actionable notification type | iOS 10 | | content-available | Number (1) | Silent background push | iOS 10 | | mutable-content | Number (1) | Triggers service extension | iOS 10 | | target-content-id | String | Window/content identifier | iOS 13 | | interruption-level | String | passive/active/time-sensitive/critical | iOS 15 | | relevance-score | Number 0-1 | Notification summary sorting | iOS 15 | | filter-criteria | String | Focus filter matching | iOS 15 | | stale-date | Number | UNIX timestamp (Live Activity) | iOS 16.1 | | content-state | Dict | Live Activity content update | iOS 16.1 | | timestamp | Number | UNIX timestamp (Live Activity) | iOS 16.1 | | event | String | start/update/end (Live Activity) | iOS 16.1 | | dismissal-date | Number | UNIX timestamp (Live Activity) | iOS 16.1 | | attributes-type | String | Live Activity struct name | iOS 17 | | attributes | Dict | Live Activity init data | iOS 17 | ### Alert Dictionary Keys | Key | Type | Purpose | |-----|------|---------| | title | String | Short title | | subtitle | String | Secondary description | | body | String | Full message | | launch-image | String | Launch screen filename | | title-loc-key | String | Localization key for title | | title-loc-args | [String] | Title format arguments | | subtitle-loc-key | String | Localization key for subtitle | | subtitle-loc-args | [String] | Subtitle format arguments | | loc-key | String | Localization key for body | | loc-args | [String] | Body format arguments | ### Sound Dictionary (Critical Alerts) ```json { "critical": 1, "name": "alarm.aiff", "volume": 0.8 } ``` ### Interruption Level Values | Value | Behavior | Requires | |-------|----------|----------| | passive | No sound/wake. Notification summary only. | Nothing | | active | Default. Sound + banner. | Nothing | | time-sensitive | Breaks scheduled delivery. Banner persists. | Time Sensitive capability | | critical | Overrides DND and ringer switch. | Apple approval + entitlement | ### Example Payloads #### Basic Alert ```json { "aps": { "alert": { "title": "New Message", "subtitle": "From Alice", "body": "Hey, are you free for lunch?" }, "badge": 3, "sound": "default" } } ``` #### Localized with loc-key/loc-args ```json { "aps": { "alert": { "title-loc-key": "MESSAGE_TITLE", "title-loc-args": ["Alice"], "loc-key": "MESSAGE_BODY", "loc-args": ["Alice", "lunch"] }, "sound": "default" } } ``` #### Silent Background Push ```json { "aps": { "content-available": 1 }, "custom-key": "sync-update" } ``` #### Rich Notification (Service Extension) ```json { "aps": { "alert": { "title": "Photo shared", "body": "Alice shared a photo with you" }, "mutable-content": 1, "sound": "default" }, "image-url": "https://example.com/photo.jpg" } ``` #### Critical Alert ```json { "aps": { "alert": { "title": "Server Down", "body": "Production database is unreachable" }, "sound": { "critical": 1, "name": "default", "volume": 1.0 }, "interruption-level": "critical" } } ``` #### Time-Sensitive with Category ```json { "aps": { "alert": { "title": "Package Delivered", "body": "Your order has been delivered to the front door" }, "interruption-level": "time-sensitive", "category": "DELIVERY", "sound": "default" }, "order-id": "12345" } ``` --- ## UNUserNotificationCenter API Reference ### Key Methods | Method | Purpose | |--------|---------| | requestAuthorization(options:) | Request permission | | notificationSettings() | Check current status | | add(_:) | Schedule notification request | | getPendingNotificationRequests() | List scheduled | | removePendingNotificationRequests(withIdentifiers:) | Cancel scheduled | | getDeliveredNotifications() | List in notification center | | removeDeliveredNotifications(withIdentifiers:) | Remove from center | | setNotificationCategories(_:) | Register actionable types | | setBadgeCount(_:) | Update badge (iOS 16+) | | supportsContentExtensions | Check content extension support | ### UNAuthorizationOptions | Option | Purpose | |--------|---------| | .alert | Display alerts | | .badge | Update badge count | | .sound | Play sounds | | .carPlay | Show in CarPlay | | .criticalAlert | Critical alerts (requires entitlement) | | .provisional | Trial delivery without prompting | | .providesAppNotificationSettings | "Configure in App" button in Settings | | .announcement | Siri announcement (deprecated iOS 15+) | ### UNAuthorizationStatus | Value | Meaning | |-------|---------| | .notDetermined | No prompt shown yet | | .denied | User denied or disabled in Settings | | .authorized | User explicitly granted | | .provisional | Provisional trial delivery | | .ephemeral | App Clip temporary | ### Request Authorization ```swift let center = UNUserNotificationCenter.current() let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge]) if granted { await MainActor.run { UIApplication.shared.registerForRemoteNotifications() } } ``` ### Check Settings ```swift let settings = await center.notificationSettings() switch settings.authorizationStatus { case .authorized: break case .denied: // Direct user to Settings case .provisional: // Upgrade to full authorization case .notDetermined: // Request authorization case .ephemeral: // App Clip — temporary @unknown default: break } ``` ### Delegate Methods ```swift // Foreground presentation — called when notification arrives while app is active func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { return [.banner, .sound, .badge] } // Action response — called when user taps notification or action button func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { let actionIdentifier = response.actionIdentifier let userInfo = response.notification.request.content.userInfo switch actionIdentifier { case UNNotificationDefaultActionIdentifier: // User tapped notification body break case UNNotificationDismissActionIdentifier: // User dismissed (requires .customDismissAction on category) break default: // Custom action break } } // Settings — called when user taps "Configure in App" from notification settings func userNotificationCenter(_ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification?) { // Navigate to in-app notification settings } ``` --- ## UNNotificationCategory and UNNotificationAction API ### Category Registration ```swift let likeAction = UNNotificationAction( identifier: "LIKE", title: "Like", options: [] ) let replyAction = UNTextInputNotificationAction( identifier: "REPLY", title: "Reply", options: [], textInputButtonTitle: "Send", textInputPlaceholder: "Type a message..." ) let deleteAction = UNNotificationAction( identifier: "DELETE", title: "Delete", options: [.destructive, .authenticationRequired] ) let messageCategory = UNNotificationCategory( identifier: "MESSAGE", actions: [likeAction, replyAction, deleteAction], intentIdentifiers: [], hiddenPreviewsBodyPlaceholder: "New message", categorySummaryFormat: "%u more messages", options: [.customDismissAction] ) UNUserNotificationCenter.current().setNotificationCategories([messageCategory]) ``` ### Action Options | Option | Effect | |--------|--------| | .authenticationRequired | Requires device unlock | | .destructive | Red text display | | .foreground | Launches app to foreground | ### Category Options | Option | Effect | |--------|--------| | .customDismissAction | Fires delegate on dismiss | | .allowInCarPlay | Show actions in CarPlay | | .hiddenPreviewsShowTitle | Show title when previews hidden | | .hiddenPreviewsShowSubtitle | Show subtitle when previews hidden | | .allowAnnouncement | Siri can announce (deprecated iOS 15+) | ### UNNotificationActionIcon (iOS 15+) ```swift let icon = UNNotificationActionIcon(systemImageName: "hand.thumbsup") let action = UNNotificationAction( identifier: "LIKE", title: "Like", options: [], icon: icon ) ``` --- ## UNNotificationServiceExtension API Modifies notification content before display. Runs in a separate extension process. ### Lifecycle | Method | Window | Purpose | |--------|--------|---------| | didReceive(_:withContentHandler:) | ~30 seconds | Modify notification content | | serviceExtensionTimeWillExpire() | Called at deadline | Deliver best attempt immediately | ### Implementation ```swift 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 content = bestAttemptContent, let imageURLString = content.userInfo["image-url"] as? String, let imageURL = URL(string: imageURLString) else { contentHandler(request.content) return } // Download and attach image let task = URLSession.shared.downloadTask(with: imageURL) { url, _, error in defer { contentHandler(content) } guard let url = url, error == nil else { return } let attachment = try? UNNotificationAttachment( identifier: "image", url: url, options: [UNNotificationAttachmentOptionsTypeHintKey: "public.jpeg"] ) if let attachment = attachment { content.attachments = [attachment] } } task.resume() } override func serviceExtensionTimeWillExpire() { if let content = bestAttemptContent { contentHandler?(content) } } } ``` ### Supported Attachment Types | Type | Extensions | Max Size | |------|-----------|----------| | Image | .jpg, .gif, .png | 10 MB | | Audio | .aif, .wav, .mp3 | 5 MB | | Video | .mp4, .mpeg | 50 MB | ### Payload Requirement The notification payload must include `"mutable-content": 1` in the `aps` dictionary for the service extension to fire. --- ## Local Notifications API ### Trigger Types | Trigger | Use Case | Repeating | |---------|----------|-----------| | UNTimeIntervalNotificationTrigger | After N seconds | Yes (≥60s) | | UNCalendarNotificationTrigger | Specific date/time | Yes | | UNLocationNotificationTrigger | Enter/exit region | Yes | ### Time Interval Trigger ```swift let content = UNMutableNotificationContent() content.title = "Reminder" content.body = "Time to take a break" content.sound = .default let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 300, repeats: false) let request = UNNotificationRequest( identifier: "break-reminder", content: content, trigger: trigger ) try await UNUserNotificationCenter.current().add(request) ``` ### Calendar Trigger ```swift var dateComponents = DateComponents() dateComponents.hour = 9 dateComponents.minute = 0 let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true) let request = UNNotificationRequest( identifier: "daily-9am", content: content, trigger: trigger ) try await UNUserNotificationCenter.current().add(request) ``` ### Location Trigger ```swift import CoreLocation let center = CLLocationCoordinate2D(latitude: 37.3349, longitude: -122.0090) let region = CLCircularRegion(center: center, radius: 100, identifier: "apple-park") region.notifyOnEntry = true region.notifyOnExit = false let trigger = UNLocationNotificationTrigger(region: region, repeats: false) let request = UNNotificationRequest( identifier: "arrived-at-office", content: content, trigger: trigger ) try await UNUserNotificationCenter.current().add(request) ``` ### Limitations | Limitation | Detail | |-----------|--------| | Minimum repeat interval | 60 seconds for UNTimeIntervalNotificationTrigger | | Location authorization | Location trigger requires When In Use or Always authorization | | No service extensions | Local notifications do not trigger UNNotificationServiceExtension | | No background wake | Local notifications cannot use content-available for background processing | | App extensions | Local notifications cannot be scheduled from app extensions (use app group + main app) | | Pending limit | 64 pending notification requests per app | --- ## Live Activity Push Headers ### Required Headers | Header | Value | |--------|-------| | apns-push-type | liveactivity | | apns-topic | {bundleID}.push-type.liveactivity | | apns-priority | 5 (routine) or 10 (time-sensitive) | ### Event Types | Event | Purpose | Required Fields | |-------|---------|----------------| | start | Start Live Activity remotely | attributes-type, attributes, content-state, timestamp | | update | Update content | content-state, timestamp | | end | End Live Activity | timestamp (content-state optional) | ### Update Payload ```json { "aps": { "timestamp": 1709913600, "event": "update", "content-state": { "homeScore": 2, "awayScore": 1, "inning": "Top 7" } } } ``` ### Start Payload (Push-to-Start Token) ```json { "aps": { "timestamp": 1709913600, "event": "start", "content-state": { "homeScore": 0, "awayScore": 0, "inning": "Top 1" }, "attributes-type": "GameAttributes", "attributes": { "homeTeam": "Giants", "awayTeam": "Dodgers" }, "alert": { "title": "Game Starting", "body": "Giants vs Dodgers is about to begin" } } } ``` ### Start Payload (Channel-Based) ```json { "aps": { "timestamp": 1709913600, "event": "start", "content-state": { "homeScore": 0, "awayScore": 0, "inning": "Top 1" }, "attributes-type": "GameAttributes", "attributes": { "homeTeam": "Giants", "awayTeam": "Dodgers" } } } ``` ### End Payload ```json { "aps": { "timestamp": 1709913600, "event": "end", "dismissal-date": 1709917200, "content-state": { "homeScore": 5, "awayScore": 3, "inning": "Final" } } } ``` ### Push-to-Start Token ```swift // Observe push-to-start tokens (iOS 17.2+) for await token in Activity.pushToStartTokenUpdates { let tokenString = token.map { String(format: "%02x", $0) }.joined() sendPushToStartTokenToServer(tokenString) } ``` ### Activity Push Token ```swift // Observe activity-specific push tokens for await tokenData in activity.pushTokenUpdates { let token = tokenData.map { String(format: "%02x", $0) }.joined() sendActivityTokenToServer(token, activityId: activity.id) } ``` Content-state encoding rule: the system always uses default JSONDecoder — do not use custom encoding strategies in your ActivityAttributes.ContentState. --- ## Broadcast Push API (iOS 18+) Server-to-many push for Live Activities without tracking individual device tokens. ### Endpoint ``` POST /4/broadcasts/apps/{TOPIC} ``` ### Headers | Header | Value | |--------|-------| | apns-push-type | liveactivity | | apns-channel-id | {channelID} | | authorization | bearer {JWT} | ### Subscribe via Channel ```swift try Activity.request( attributes: attributes, content: .init(state: initialState, staleDate: nil), pushType: .channel(channelId) ) ``` ### Channel Storage Policies | Policy | Behavior | Budget | |--------|----------|--------| | No Storage | Deliver only to connected devices | Higher | | Most Recent Message | Store latest for offline devices | Lower | --- ## Command-Line Testing ### JWT Generation ```bash JWT_ISSUE_TIME=$(date +%s) JWT_HEADER=$(printf '{ "alg": "ES256", "kid": "%s" }' "${AUTH_KEY_ID}" | openssl base64 -e -A | tr -- '+/' '-_' | tr -d =) JWT_CLAIMS=$(printf '{ "iss": "%s", "iat": %d }' "${TEAM_ID}" "${JWT_ISSUE_TIME}" | openssl base64 -e -A | tr -- '+/' '-_' | tr -d =) JWT_HEADER_CLAIMS="${JWT_HEADER}.${JWT_CLAIMS}" JWT_SIGNED_HEADER_CLAIMS=$(printf "${JWT_HEADER_CLAIMS}" | openssl dgst -binary -sha256 -sign "${TOKEN_KEY_FILE_NAME}" | openssl base64 -e -A | tr -- '+/' '-_' | tr -d =) AUTHENTICATION_TOKEN="${JWT_HEADER}.${JWT_CLAIMS}.${JWT_SIGNED_HEADER_CLAIMS}" ``` ### Send Alert Push ```bash curl -v \ --header "apns-topic: $TOPIC" \ --header "apns-push-type: alert" \ --header "authorization: bearer $AUTHENTICATION_TOKEN" \ --data '{"aps":{"alert":"test"}}' \ --http2 https://${APNS_HOST_NAME}/3/device/${DEVICE_TOKEN} ``` ### Send Live Activity Push ```bash curl \ --header "apns-topic: com.example.app.push-type.liveactivity" \ --header "apns-push-type: liveactivity" \ --header "apns-priority: 10" \ --header "authorization: bearer $AUTHENTICATION_TOKEN" \ --data '{ "aps": { "timestamp": '$(date +%s)', "event": "update", "content-state": { "score": "2-1" } } }' \ --http2 https://api.sandbox.push.apple.com/3/device/$ACTIVITY_PUSH_TOKEN ``` ### Simulator Push ```bash xcrun simctl push booted com.example.app payload.json ``` ### Simulator Payload File ```json { "Simulator Target Bundle": "com.example.app", "aps": { "alert": { "title": "Test", "body": "Hello" }, "sound": "default" } } ``` --- ## Resources **WWDC**: 2021-10091, 2023-10025, 2023-10185, 2024-10069 **Docs**: /usernotifications, /usernotifications/sending-notification-requests-to-apns, /usernotifications/generating-a-remote-notification, /activitykit **Skills**: axiom-push-notifications, axiom-push-notifications-diag, axiom-extensions-widgets