# Swift Recipe (native iOS)

Register a native iOS app's APNs device token with Native Notify's push API from Swift — delegate, hex token, URLSession registration, and the Apple credentials it needs.

Native iOS apps talk to APNs directly; the device token that arrives in the app delegate is exactly what the universal service registers.

## 1. Ask for permission and register with APNs

```swift title="AppDelegate.swift"
import UIKit
import UserNotifications

@main
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
    func application(_ application: UIApplication,
                     didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        UNUserNotificationCenter.current().delegate = self
        UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, _ in
            guard granted else { return }
            DispatchQueue.main.async { application.registerForRemoteNotifications() }
        }
        return true
    }

    func application(_ application: UIApplication,
                     didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
        let apnsToken = deviceToken.map { String(format: "%02x", $0) }.joined()
        Task { await UniversalPush.register(appId: 123, appToken: "yourAppToken", apnsToken: apnsToken) }
    }

    func application(_ application: UIApplication,
                     didFailToRegisterForRemoteNotificationsWithError error: Error) {
        print("APNs registration failed:", error) // usually the Push Notifications capability is missing
    }

    // Show the banner while the app is in the foreground too.
    nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter,
                                            willPresent notification: UNNotification,
                                            withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
        completionHandler([.banner, .list, .sound])
    }

    // A tap: your pushData keys sit at the top level of userInfo (and the whole object under "body").
    nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter,
                                            didReceive response: UNNotificationResponse,
                                            withCompletionHandler completionHandler: @escaping () -> Void) {
        let userInfo = response.notification.request.content.userInfo
        if let url = userInfo["url"] as? String {
            print("Open", url) // route to it in your app
        }
        completionHandler()
    }
}
```

- **Push Notifications capability:** in Xcode, *Signing & Capabilities → + Capability → Push Notifications*. Without it iOS never hands out a token (`didFailToRegisterForRemoteNotificationsWithError` reports a missing `aps-environment` entitlement).
- **SwiftUI app?** Remove `@main` from the delegate and attach it to your `App` struct instead: `@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate`.
- APNs can re-deliver `didRegisterForRemoteNotificationsWithDeviceToken` at any launch (and rotates the token on restore) — registering every time is correct: it is an idempotent upsert for the same `deviceId`.
- Taps: your `pushData` keys arrive at the top level of `userInfo` (the whole object is also under `userInfo["body"]`), together with `nn_notification_id`.

## 2. Register with Native Notify

```swift title="UniversalPush.swift"
import Foundation

enum UniversalPush {
    static func register(appId: Int, appToken: String, apnsToken: String, subscriberId: String? = nil) async {
        guard let url = URL(string: "https://app.nativenotify.com/api/universal/device/register") else { return }

        var body: [String: Any] = [
            "appId": appId,
            "appToken": appToken,
            "deviceId": DeviceIdentity.stableKey(),
            "platform": "ios",
            "tokens": ["apnsToken": apnsToken],
        ]
        if let subscriberId { body["subscriberId"] = subscriberId }

        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = try? JSONSerialization.data(withJSONObject: body)

        do {
            let (data, response) = try await URLSession.shared.data(for: request)
            let status = (response as? HTTPURLResponse)?.statusCode ?? 0
            if status != 201 {
                print("Native Notify register failed (\(status)):", String(decoding: data, as: UTF8.self))
            }
        } catch {
            print("Native Notify register error:", error)
        }
    }
}

/// A per-install key kept in the Keychain. `ThisDeviceOnly` keeps a backup
/// restore from copying the same key onto a second phone.
enum DeviceIdentity {
    private static let account = "nn-universal-device-id"

    static func stableKey() -> String {
        if let saved = read() { return saved }
        let fresh = UUID().uuidString
        let attributes: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: account,
            kSecValueData as String: Data(fresh.utf8),
            kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
        ]
        SecItemAdd(attributes as CFDictionary, nil)
        return fresh
    }

    private static func read() -> String? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: account,
            kSecReturnData as String: true,
            kSecMatchLimit as String: kSecMatchLimitOne,
        ]
        var item: CFTypeRef?
        guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
              let data = item as? Data else { return nil }
        return String(data: data, encoding: .utf8)
    }
}
```

`DeviceIdentity` keeps one UUID per device in the Keychain: it survives relaunches and reinstalls, and `…ThisDeviceOnly` stops a backup restore from copying it onto a second phone. Never generate a new id per launch — every distinct `deviceId` is a separate device.

Pass `subscriberId` when a user is logged in; omit it for an anonymous device.

## 3. Credentials the app needs

Save the app's **Apple `.p8`** key with its Key ID, Team ID and the app's Bundle ID as the app's credentials in Native Notify — [Push Credentials](/docs/push/credentials). The Bundle ID must be this app's: a token that belongs to another app answers `DeviceTokenNotForTopic`.

## 4. Send and verify

```bash
curl -X POST https://app.nativenotify.com/api/universal/notifications/send \
  -H "Content-Type: application/json" \
  -d '{"appId":123,"appToken":"yourAppToken","title":"Hello iOS","message":"Delivered over APNs.","audience":{"type":"all"}}'
```

`"all"` reaches every registered device, with or without a `subscriberId`; to reach one logged-in user's devices, send `{"type":"subscribers","subscriberIds":["user_8241"]}` instead — see [Send Notifications](/docs/push/sending).

Confirm one device first with [`test-send`](/docs/push/verification) and its `deviceId` — for iOS it returns APNs' answer immediately: `accepted`, or `failed` with a `reasonCode` such as `Unregistered` (the app was uninstalled or the token revoked) or `BadDeviceToken` (not a valid token for this app — a debug build's sandbox token is retried on the sandbox host automatically).

## Deregister on logout

```swift
import Foundation

extension UniversalPush {
    static func deregister(appId: Int, appToken: String) async {
        guard let url = URL(string: "https://app.nativenotify.com/api/universal/device/deregister") else { return }
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = try? JSONSerialization.data(withJSONObject: [
            "appId": appId, "appToken": appToken, "deviceId": DeviceIdentity.stableKey(),
        ] as [String: Any])
        _ = try? await URLSession.shared.data(for: request)
    }
}
```

Deregister is a hard delete (idempotent — safe to call more than once). See [Device Registration](/docs/push/registration).
