skills/charleswiltgen/axiom/axiom-push-notifications-ref

axiom-push-notifications-ref

SKILL.md

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

// 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

{ "alg": "ES256", "kid": "{10-char Key ID}" }

JWT Claims

{ "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)

{ "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

{
    "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

{
    "aps": {
        "alert": {
            "title-loc-key": "MESSAGE_TITLE",
            "title-loc-args": ["Alice"],
            "loc-key": "MESSAGE_BODY",
            "loc-args": ["Alice", "lunch"]
        },
        "sound": "default"
    }
}

Silent Background Push

{
    "aps": {
        "content-available": 1
    },
    "custom-key": "sync-update"
}

Rich Notification (Service Extension)

{
    "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

{
    "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

{
    "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

let center = UNUserNotificationCenter.current()

let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge])
if granted {
    await MainActor.run {
        UIApplication.shared.registerForRemoteNotifications()
    }
}

Check Settings

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

// 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

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+)

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

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

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

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

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

{
    "aps": {
        "timestamp": 1709913600,
        "event": "update",
        "content-state": {
            "homeScore": 2,
            "awayScore": 1,
            "inning": "Top 7"
        }
    }
}

Start Payload (Push-to-Start Token)

{
    "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)

{
    "aps": {
        "timestamp": 1709913600,
        "event": "start",
        "content-state": {
            "homeScore": 0,
            "awayScore": 0,
            "inning": "Top 1"
        },
        "attributes-type": "GameAttributes",
        "attributes": {
            "homeTeam": "Giants",
            "awayTeam": "Dodgers"
        }
    }
}

End Payload

{
    "aps": {
        "timestamp": 1709913600,
        "event": "end",
        "dismissal-date": 1709917200,
        "content-state": {
            "homeScore": 5,
            "awayScore": 3,
            "inning": "Final"
        }
    }
}

Push-to-Start Token

// 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)
}

Activity Push Token

// 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

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

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

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

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

xcrun simctl push booted com.example.app payload.json

Simulator Payload File

{
    "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

Weekly Installs
17
GitHub Stars
631
First Seen
5 days ago
Installed on
github-copilot16
gemini-cli16
codex16
amp16
cline16
kimi-cli16