Installable skillMIT

Push Notifications API Reference

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

BY CharlesWiltgenUPDATED Oct 27, 2026
INTENDED FOR◈OpenClawInstall in a listed agent.

instructions.md

---
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<GameAttributes>.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

COMPATIBILITY & REFERENCES

Platform guidance

Imported from the listed AgentSkill.sh category. Complete supplied package files and notices are retained. Required external tools and credentials are not installed. No bundled code was executed during import. Individual agent task behavior has not been tested.