---
name: push-notifications
description: "UserNotifications and APNs on iOS and macOS, local and remote: permission (standard, provisional, critical), device-token registration, triggers, payloads (alert, background, mutable, localized), foreground presentation, tap handling and deep links, categories and actions, grouping, service and content extensions, media attachments, communication notifications, delivery debugging. Use when building, reviewing or debugging notifications, badges, sounds, silent pushes or notification permission. Not for Live Activity, VoIP or App Clip pushes, or post-push background work."
metadata:
  source: multi-agent-pipeline
---

# Push Notifications

Notifications on Apple platforms come from two places: the device itself
(local notifications scheduled through `UserNotifications`) and a server that
talks to the Apple Push Notification service (APNs). Both end up in the same
`UNUserNotificationCenter`, so permission, presentation, actions and grouping
work the same way for each.

Baseline: iOS 26 and Swift 6.3. Everything here also works back to iOS 16
unless a section names a newer release.

Hand these to their own skills:

| Topic | Skill |
|-------|-------|
| Live Activity `content-state` pushes | `live-activities` |
| PushKit and VoIP call pushes | `callkit-voip` |
| Ephemeral App Clip notifications | `app-clips` |
| Scheduled or long background work triggered by a push | `background-processing` |

Longer material:
the [app-side patterns](references/notification-patterns.md) (launch wiring,
the delegate, routing taps to screens, silent-push handling, a scheduler, the
token lifecycle, testing, badges) and the
[extensions guide](references/rich-notifications.md) (service and content
extensions, attachments, communication notifications).

## Reviewing Someone Else's Design

When a proposal is wrong, say which contract it breaks, then give the fix.
"Move this call" is not enough; "registration does not depend on alert
permission, so this gate blocks silent pushes" is. The contracts that come up
most often:

**Device token**
- Getting an APNs token and getting alert permission are two separate things.
- Send the token to the server from every `didRegister` callback.
- A token saved on the device is a cache, never the truth.
- Token length is not fixed; never hard-code or validate a size.
- On Simulator, registration fails by design. Pushes can still be simulated
  with a `.apns` file or `xcrun simctl push`.

**Background (silent) push**
- Payload carries `content-available: 1` and nothing that alerts: no alert, sound or badge.
- Send it at priority 5 (`apns-priority: 5`) with the header `apns-push-type: background`.
- Turn on Remote notifications under the app's Background Modes capability.
- APNs treats it as low priority and throttles it; it may never arrive. It is
  not a scheduler, so a cadence of one every few minutes will not happen.
- `didReceiveRemoteNotification` does short, bounded work and returns the
  `UIBackgroundFetchResult` that matches what actually happened.

**Rich notifications**
- A service extension runs only for an alerting push with `mutable-content: 1`.
  A silent push never reaches it.
- Attachments must be supported files already on disk; the system checks them
  and moves them into its own store.
- Secrets such as decryption keys live in a shared Keychain access group. App
  Groups are for shared files and `UserDefaults` only.
- Communication notifications need four things: the capability, an
  `NSUserActivityTypes` entry, a donated `INInteraction`, and a call to
  `content.updating(from:)`.
- The content handler is called exactly once on every path: success, failed
  download, failed decryption, and `serviceExtensionTimeWillExpire()`. It
  receives the updated content, the best attempt so far, or the original.

## Permission

Ask before scheduling anything the user will see or hear, or before setting a
badge. iOS shows the system prompt a single time; after that the call returns
whatever the user chose. Registering with APNs is a different path and may
happen even if the user said no to alerts.

```swift
@MainActor
func askForAlertPermission() async -> Bool {
    do {
        return try await UNUserNotificationCenter.current()
            .requestAuthorization(options: [.alert, .sound, .badge])
    } catch {
        return false
    }
}
```

### Reading the current setting

The user can change the setting in Settings at any moment, so read it instead
of remembering an old answer.

```swift
let center = UNUserNotificationCenter.current()
let current = await center.notificationSettings()
switch current.authorizationStatus {
case .notDetermined: break   // never asked
case .denied: break          // user said no
case .authorized: break
case .provisional: break     // quiet delivery, no prompt shown
case .ephemeral: break       // App Clip
@unknown default: break
}
```

### Provisional authorization

Adding `.provisional` to the options skips the prompt. Notifications go
straight to Notification Center without sound or banner, and the user decides
later whether to keep them. It suits onboarding: show value first, ask for full
permission afterwards.

### Critical alerts

`.criticalAlert` plays through Do Not Disturb and the ring/silent switch. It
needs the `com.apple.developer.usernotifications.critical-alerts` entitlement,
which Apple grants on request through the developer portal. Reserve it for
health, safety and security situations.

### When the user said no

Do not ask again and do not nag. Offer a button that opens the app's page in
Settings through `UIApplication.openSettingsURLString`.

## Registering with APNs

A SwiftUI app still needs an app delegate for this: the device token is only
delivered to `UIApplicationDelegate` callbacks. Attach one with
`@UIApplicationDelegateAdaptor`.

```swift
final class AppDelegate: NSObject, UIApplicationDelegate {
    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]? = nil
    ) -> Bool {
        UNUserNotificationCenter.current().delegate = InboxNotificationDelegate.shared
        return true
    }

    func application(_ application: UIApplication,
                     didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
        let hex = deviceToken.reduce(into: "") { $0 += String(format: "%02x", $1) }
        Task { await DeviceTokenUploader.shared.send(hex) }
    }

    func application(_ application: UIApplication,
                     didFailToRegisterForRemoteNotificationsWithError error: Error) {
        // Expected on Simulator: it can show simulated pushes but never gets an APNs token.
    }
}
```

### Order of calls

1. At launch: set the delegate and register categories.
2. At a moment that makes sense to the user: ask for alert permission.
3. Whenever a token is needed: call `registerForRemoteNotifications()`.

Do not wait for `.authorized` before step 3. An app without alert permission
still receives remote notifications, just silently, and the server still needs
the token.

```swift
@MainActor
func prepareNotifications() async {
    let center = UNUserNotificationCenter.current()
    if await center.notificationSettings().authorizationStatus == .notDetermined {
        _ = try? await center.requestAuthorization(options: [.alert, .badge, .sound])
    }
    UIApplication.shared.registerForRemoteNotifications()   // token + silent pushes
}
```

### Token rules

The token can change (restore, reinstall, new device, OS update). Upload it
from every `didRegisterForRemoteNotificationsWithDeviceToken` call, not only the
first one. Do not keep a local copy as the source of truth, and do not assume a
length.

## Local Notifications

Local notifications need no server: the device schedules and shows them.
Reminders, timers and arriving at a place are the usual cases.

```swift
let content = UNMutableNotificationContent()
content.title = "Plants"
content.subtitle = "Balcony"
content.body = "The basil has not been watered for three days."
content.sound = .default
content.badge = NSNumber(value: 1)
content.userInfo = ["plantID": "basil-01"]
content.threadIdentifier = "garden"   // groups these together in Notification Center
```

### Triggers

```swift
// Once, 15 minutes from now. A repeating interval must be at least 60 seconds.
let soon = UNTimeIntervalNotificationTrigger(timeInterval: 900, repeats: false)

// Every day at 07:30.
var morning = DateComponents()
morning.hour = 7
morning.minute = 30
let daily = UNCalendarNotificationTrigger(dateMatching: morning, repeats: true)

// On arriving at a place. Needs at least When In Use location permission.
let gym = CLCircularRegion(
    center: CLLocationCoordinate2D(latitude: 52.37, longitude: 4.89),
    radius: 150,
    identifier: "gym"
)
gym.notifyOnEntry = true
gym.notifyOnExit = false
let arrival = UNLocationNotificationTrigger(region: gym, repeats: false)
```

### Scheduling and cleanup

```swift
let center = UNUserNotificationCenter.current()
let request = UNNotificationRequest(identifier: "water-basil", content: content, trigger: daily)
try await center.add(request)

let waiting = await center.pendingNotificationRequests()

center.removePendingNotificationRequests(withIdentifiers: ["water-basil"])
center.removeAllPendingNotificationRequests()
center.removeDeliveredNotifications(withIdentifiers: ["water-basil"])
center.removeAllDeliveredNotifications()
```

## Remote Payloads

### Alert push

```json
{
  "aps": {
    "alert": { "title": "Order shipped", "subtitle": "#4821", "body": "Arrives Thursday." },
    "badge": 2,
    "sound": "default",
    "thread-id": "orders",
    "category": "ORDER_UPDATE"
  },
  "orderID": "4821"
}
```

App-specific keys go beside `aps`, never inside it.

### Background push

```json
{ "aps": { "content-available": 1 }, "syncScope": "inbox" }
```

- No `alert`, `sound` or `badge`.
- Background Modes > Remote notifications must be on.
- Header `apns-push-type: background`, priority `apns-priority: 5`.
- Expect throttling and occasional drops. A push every few minutes will be
  held back, and data freshness must not hinge on one arriving.
- Finish quickly and return a result inside the short background window.

```swift
func application(_ application: UIApplication,
                 didReceiveRemoteNotification userInfo: [AnyHashable: Any]) async
    -> UIBackgroundFetchResult {
    guard let scope = userInfo["syncScope"] as? String else { return .noData }
    do {
        try await MailboxSync.shared.refresh(scope: scope)
        return .newData
    } catch {
        return .failed
    }
}
```

### Mutable content

Adding `mutable-content: 1` to an alerting payload hands it to your
Notification Service Extension before it is shown. Silent pushes do not start
the extension.

```json
{
  "aps": {
    "alert": { "title": "New photo", "body": "Ada shared a picture." },
    "mutable-content": 1
  },
  "mediaURL": "https://media.example.com/p/77.jpg"
}
```

Good jobs for the extension, all kept short: fetch a supported attachment to
disk, decrypt the visible text, and turn the push into a communication
notification (capability, `NSUserActivityTypes`, `INInteraction` donation, then
`content.updating(from:)`). Every outcome, including timeout, ends with exactly
one call to the content handler. See the
[extensions guide](references/rich-notifications.md#service-extension).

### Localized payloads

Let the device pick the language by sending keys instead of text:

```json
{ "aps": { "alert": { "title-loc-key": "SHIPPED_TITLE", "loc-key": "SHIPPED_BODY", "loc-args": ["Thursday"] } } }
```

## Handling Notifications

### The delegate

Install `UNUserNotificationCenter.current().delegate` before launch finishes,
from `App.init` or from `application(_:didFinishLaunchingWithOptions:)`.

```swift
@MainActor
final class InboxNotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
    static let shared = InboxNotificationDelegate()

    nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter,
                                            willPresent notification: UNNotification) async
        -> UNNotificationPresentationOptions {
        [.banner, .sound, .badge]
    }

    nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter,
                                            didReceive response: UNNotificationResponse) async {
        let action = response.actionIdentifier
        let threadID = response.notification.request.content.userInfo["threadID"] as? String
        let typed = (response as? UNTextInputNotificationResponse)?.userText
        await route(action: action, threadID: threadID, typed: typed)
    }

    private func route(action: String, threadID: String?, typed: String?) async {
        switch action {
        case UNNotificationDefaultActionIdentifier:
            AppRouter.shared.openThread(threadID)       // tapped the notification
        case UNNotificationDismissActionIdentifier:
            break                                       // swiped away
        default:
            await handleChatAction(action, threadID: threadID, typed: typed)
        }
    }
}
```

The system does not promise to call these methods on the main thread, and
`UNNotification` and `UNNotificationResponse` are not `Sendable`. Mark the
protocol methods `nonisolated`, copy the plain values you need out of the
response (all payload data sits in `content.userInfo`), then hop to the main
actor.

Leave out `willPresent` and anything that arrives while your app is frontmost
is dropped without a banner.

### Deep links from a tap

Keep a single `@Observable @MainActor` router with an optional
`pendingDestination`. The delegate sets it; the root view watches it with
`.onChange(of:)`, pushes the destination onto its navigation path and then sets
it back to `nil`. A version that also switches tabs lives in the
[patterns reference](references/notification-patterns.md#routing-a-tap-to-a-screen).

## Actions and Categories

Register categories at launch, before any notification can arrive.

```swift
let reply = UNTextInputNotificationAction(
    identifier: "REPLY",
    title: "Reply",
    options: [],
    textInputButtonTitle: "Send",
    textInputPlaceholder: "Message"
)
let archive = UNNotificationAction(identifier: "ARCHIVE", title: "Archive", options: [.destructive])
let open = UNNotificationAction(identifier: "OPEN", title: "Open", options: [.foreground])
let approve = UNNotificationAction(identifier: "APPROVE", title: "Approve", options: [.authenticationRequired])

let chat = UNNotificationCategory(
    identifier: "CHAT",
    actions: [reply, archive],
    intentIdentifiers: [],
    options: [.customDismissAction]    // dismissal also reaches didReceive
)
let request = UNNotificationCategory(identifier: "EXPENSE", actions: [approve, open], intentIdentifiers: [])

UNUserNotificationCenter.current().setNotificationCategories([chat, request])
```

| Option | Effect |
|--------|--------|
| `.authenticationRequired` | Runs only after the device is unlocked |
| `.destructive` | Shown in red; for delete or remove |
| `.foreground` | Tapping it opens the app |

A text action's response arrives as a `UNTextInputNotificationResponse`; its
`userText` is what the delegate above passes on as `typed`:

```swift
@MainActor
func handleChatAction(_ action: String, threadID: String?, typed: String?) async {
    guard let threadID else { return }
    switch action {
    case "REPLY":
        guard let typed, !typed.isEmpty else { return }
        await ChatService.shared.send(typed, to: threadID)
    case "ARCHIVE":
        await ChatService.shared.archive(threadID)
    default:
        break
    }
}
```

## Grouping

Notifications with the same `threadIdentifier` (local) or `thread-id` (APNs)
stack into one group; each distinct value is its own group. Grouping comes
only from that identifier. `summaryArgument` and `summaryArgumentCount` on
`UNMutableNotificationContent` are deprecated as ignored on iOS 15 and later
(watchOS 8, tvOS 15); do not set them. A category can still supply a summary
format through `categorySummaryFormat` (iOS 12+, not deprecated). The argument
list in that format is optional, and with the per-notification argument ignored
a count-only format is the one to rely on:

```swift
let chat = UNNotificationCategory(
    identifier: "CHAT",
    actions: [],
    intentIdentifiers: [],
    hiddenPreviewsBodyPlaceholder: nil,
    categorySummaryFormat: "%u new messages",
    options: []
)
```

## Common Mistakes

| Mistake | Instead |
|---------|---------|
| Calling `registerForRemoteNotifications()` only after `.authorized` | Register anyway; silent pushes and server token binding need it |
| `String(data: token, encoding: .utf8)` | Hex-encode each byte |
| Promising frequent silent refreshes | Expect a few per hour at most |
| Expecting a silent push to run the service extension | Only `mutable-content: 1` alert pushes do |
| A service-extension path that never calls the handler | Call it once on every path, timeout included |
| No `willPresent` | Implement it or foreground notifications vanish |
| Setting the delegate late, or registering from a view without the adaptor | Set it in `App.init`, use `@UIApplicationDelegateAdaptor` |
| Uploading the token only when it "changes" | Treat it as opaque hex and upload on every callback |
| Mixing in rules for Live Activities, VoIP calls or App Clips | Hand those to `live-activities`, `callkit-voip`, `app-clips` |

## Review Checklist

- [ ] Permission is requested before any alert, sound or badge, and a denied user gets a link to Settings
- [ ] APNs registration does not depend on alert authorization
- [ ] Token is hex-encoded, uploaded on every callback, not cached as truth, no fixed length
- [ ] The center delegate is installed from `App.init` or `application(_:didFinishLaunchingWithOptions:)`
- [ ] Both `willPresent` and `didReceive` are implemented
- [ ] Categories and actions are registered at launch when notifications are interactive
- [ ] Silent pushes carry only `content-available: 1` (nothing that alerts), use the `background` push type at priority 5, rely on the Remote notifications mode, accept throttling, and report an honest `UIBackgroundFetchResult`

## Apple Documentation

- [APNs registration](https://developer.apple.com/documentation/usernotifications/registering-your-app-with-apns)
- [Requesting permission](https://developer.apple.com/documentation/usernotifications/asking-permission-to-use-notifications)
- [Building a remote payload](https://developer.apple.com/documentation/usernotifications/generating-a-remote-notification)
- [Background updates by push](https://developer.apple.com/documentation/usernotifications/pushing-background-updates-to-your-app)
