axiom-codex/skills/axiom-push-notifications-ref/SKILL.md
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.
npx skillsauth add charleswiltgen/axiom axiom-push-notifications-refInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Comprehensive API reference for APNs HTTP/2 transport, UserNotifications framework, and push-driven features including Live Activities and broadcast push.
// 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
}
}
| Environment | Host | Port | |-------------|------|------| | Development | api.sandbox.push.apple.com | 443 or 2197 | | Production | api.push.apple.com | 443 or 2197 |
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
| 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 |
| 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 |
{ "alg": "ES256", "kid": "{10-char Key ID}" }
{ "iss": "{10-char Team ID}", "iat": {unix_timestamp} }
| 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: bearer eyAia2lkIjog...
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 |
| 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 |
{ "critical": 1, "name": "alarm.aiff", "volume": 0.8 }
| 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 |
{
"aps": {
"alert": {
"title": "New Message",
"subtitle": "From Alice",
"body": "Hey, are you free for lunch?"
},
"badge": 3,
"sound": "default"
}
}
{
"aps": {
"alert": {
"title-loc-key": "MESSAGE_TITLE",
"title-loc-args": ["Alice"],
"loc-key": "MESSAGE_BODY",
"loc-args": ["Alice", "lunch"]
},
"sound": "default"
}
}
{
"aps": {
"content-available": 1
},
"custom-key": "sync-update"
}
{
"aps": {
"alert": {
"title": "Photo shared",
"body": "Alice shared a photo with you"
},
"mutable-content": 1,
"sound": "default"
},
"image-url": "https://example.com/photo.jpg"
}
{
"aps": {
"alert": {
"title": "Server Down",
"body": "Production database is unreachable"
},
"sound": { "critical": 1, "name": "default", "volume": 1.0 },
"interruption-level": "critical"
}
}
{
"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"
}
| 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 |
| 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+) |
| 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 |
let center = UNUserNotificationCenter.current()
let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge])
if granted {
await MainActor.run {
UIApplication.shared.registerForRemoteNotifications()
}
}
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
}
// 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
}
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])
| Option | Effect | |--------|--------| | .authenticationRequired | Requires device unlock | | .destructive | Red text display | | .foreground | Launches app to foreground |
| 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+) |
let icon = UNNotificationActionIcon(systemImageName: "hand.thumbsup")
let action = UNNotificationAction(
identifier: "LIKE",
title: "Like",
options: [],
icon: icon
)
Modifies notification content before display. Runs in a separate extension process.
| Method | Window | Purpose | |--------|--------|---------| | didReceive(_:withContentHandler:) | ~30 seconds | Modify notification content | | serviceExtensionTimeWillExpire() | Called at deadline | Deliver best attempt immediately |
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)
}
}
}
| Type | Extensions | Max Size | |------|-----------|----------| | Image | .jpg, .gif, .png | 10 MB | | Audio | .aif, .wav, .mp3 | 5 MB | | Video | .mp4, .mpeg | 50 MB |
The notification payload must include "mutable-content": 1 in the aps dictionary for the service extension to fire.
| Trigger | Use Case | Repeating | |---------|----------|-----------| | UNTimeIntervalNotificationTrigger | After N seconds | Yes (≥60s) | | UNCalendarNotificationTrigger | Specific date/time | Yes | | UNLocationNotificationTrigger | Enter/exit region | Yes |
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)
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)
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)
| 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 |
| Header | Value | |--------|-------| | apns-push-type | liveactivity | | apns-topic | {bundleID}.push-type.liveactivity | | apns-priority | 5 (routine) or 10 (time-sensitive) |
| 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) |
{
"aps": {
"timestamp": 1709913600,
"event": "update",
"content-state": {
"homeScore": 2,
"awayScore": 1,
"inning": "Top 7"
}
}
}
{
"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"
}
}
}
{
"aps": {
"timestamp": 1709913600,
"event": "start",
"content-state": {
"homeScore": 0,
"awayScore": 0,
"inning": "Top 1"
},
"attributes-type": "GameAttributes",
"attributes": {
"homeTeam": "Giants",
"awayTeam": "Dodgers"
}
}
}
{
"aps": {
"timestamp": 1709913600,
"event": "end",
"dismissal-date": 1709917200,
"content-state": {
"homeScore": 5,
"awayScore": 3,
"inning": "Final"
}
}
}
// Observe push-to-start tokens (iOS 17.2+)
for await token in Activity<GameAttributes>.pushToStartTokenUpdates {
let tokenString = token.map { String(format: "%02x", $0) }.joined()
sendPushToStartTokenToServer(tokenString)
}
// 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.
Server-to-many push for Live Activities without tracking individual device tokens.
POST /4/broadcasts/apps/{TOPIC}
| Header | Value | |--------|-------| | apns-push-type | liveactivity | | apns-channel-id | {channelID} | | authorization | bearer {JWT} |
try Activity.request(
attributes: attributes,
content: .init(state: initialState, staleDate: nil),
pushType: .channel(channelId)
)
| Policy | Behavior | Budget | |--------|----------|--------| | No Storage | Deliver only to connected devices | Higher | | Most Recent Message | Store latest for offline devices | Lower |
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}"
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}
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
xcrun simctl push booted com.example.app payload.json
{
"Simulator Target Bundle": "com.example.app",
"aps": {
"alert": { "title": "Test", "body": "Hello" },
"sound": "default"
}
}
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
development
Use when building ANY watchOS app — app structure, independent apps, Watch Connectivity, Smart Stack widgets, complications, controls, RelevanceKit, background tasks, ClockKit migration.
development
Use when working with HealthKit, WorkoutKit, health data, workouts, or fitness features on iOS or watchOS. Covers permissions, queries, background delivery, custom workouts, multidevice coordination.
development
Use when building, fixing, or improving ANY SwiftUI UI — views, navigation, layout, animations, performance, architecture, gestures, debugging, iOS 26 features.
content-media
Use when working with camera, photos, audio, haptics, ShazamKit, or Now Playing. Covers AVCaptureSession, PHPicker, PhotosPicker, AVFoundation, Core Haptics, audio recognition, MediaPlayer, CarPlay, MusicKit.