Platform guides

Swift recipe (native iOS)

Register an iOS app's APNs token with Native Notify from Swift, and handle foreground pushes and taps.

1. Set up the project

Add the Push Notifications capability under Signing & Capabilities — without it iOS never hands out a token. In a SwiftUI app, remove @main from the delegate and attach it with @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate.

2. Ask for permission and get the token

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

3. Register the device

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

Never create a new deviceId per launch — each one is a separate device. Pass subscriberId for a logged-in user.

4. Save the APNs key

Save the .p8 key with its Key ID, your Team ID and the app's bundle id under App Settings → Push credentials. See Push credentials. A token from another app answers DeviceTokenNotForTopic.

5. Send a push

Check one device first with a test send; a debug build's sandbox token is retried on the sandbox host. For other audiences, see Send notifications. This call reaches every registered device:

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"}}'

Deregister on logout

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, idempotent delete. See Device registration.