Platform guides

Swift recipe (native iOS)

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

Before you start

  • Apple Developer Program ($99 a year): a free Apple ID cannot create the APNs key. Enroll.
  • A Bundle ID that is yours, registered with Push Notifications ticked: Identifiers → + → App IDs → App → Explicit. Xcode registers it when you pick your Team with automatic signing on. A template ID such as com.example.MyApp cannot be registered.
  • The APNs key and your Team ID, saved in Native Notify as Push credentials.
  • Where it runs: an iPhone Simulator (Xcode 14 or newer, iOS 16 or newer, a Mac with Apple silicon or a T2 chip) or a real iPhone. Press ⌘R in Xcode; there is nothing to build or install first.

1. Set up the project in Xcode

In Xcode, select your app target and add the Push Notifications capability under Signing & Capabilities (+ Capability) — without it iOS never hands out a token, and didFailToRegisterForRemoteNotificationsWithError reports no valid “aps-environment” entitlement string found. That is a missing build setting, not a Simulator limit. 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 in Native Notify

In Native Notify, open your project's Settings → Push credentials and save the .p8 key with its Key ID, your Team ID and the app's bundle id. See Push credentials. A token from another app answers DeviceTokenNotForTopic.

5. Send a push with the API

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

Show images

iOS never downloads a push's image by itself. A send with bigPictureURL sets mutable-content: 1 and puts the URL in the payload's image key; a notification service extension in your app downloads it before the notification appears.

  1. In Xcode, choose File → New → Target → Notification Service Extension, name it NotificationService, and answer Cancel to "Activate scheme".
  2. Select the new target. Under Signing & Capabilities pick the same Team as your app, and under General set Minimum Deployments to your app's.
  3. Replace the Swift file Xcode created with:
NotificationService/NotificationService.swift
// Shows the image of a push that carries one (`bigPictureURL` in the API).
// iOS never downloads a remote image by itself: before the notification appears,
// this extension gets about 30 seconds to fetch it and attach it.

import UserNotifications

final class NotificationService: UNNotificationServiceExtension {
    private var delivery: Delivery?

    override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
        let content = (request.content.mutableCopy() as? UNMutableNotificationContent) ?? UNMutableNotificationContent()
        let delivery = Delivery(content: content, handler: contentHandler)
        self.delivery = delivery

        guard let url = NativeNotifyImage.url(in: request.content.userInfo) else {
            delivery.finish()
            return
        }
        var download = URLRequest(url: url)
        download.timeoutInterval = 20
        URLSession.shared.downloadTask(with: download) { location, response, _ in
            if let location = location, let attachment = NativeNotifyImage.attachment(from: location, response: response, url: url) {
                delivery.attach(attachment)
            }
            delivery.finish()
        }.resume()
    }

    override func serviceExtensionTimeWillExpire() {
        // Out of time: deliver the notification as it is.
        delivery?.finish()
    }
}

/// Hands the notification to iOS exactly once — when the download finishes or when time runs out, whichever comes first.
private final class Delivery: @unchecked Sendable {
    private let lock = NSLock()
    private let content: UNMutableNotificationContent
    private let handler: (UNNotificationContent) -> Void
    private var finished = false

    init(content: UNMutableNotificationContent, handler: @escaping (UNNotificationContent) -> Void) {
        self.content = content
        self.handler = handler
    }

    func attach(_ attachment: UNNotificationAttachment) {
        lock.lock()
        defer { lock.unlock() }
        if !finished { content.attachments = [attachment] }
    }

    func finish() {
        lock.lock()
        let alreadyFinished = finished
        finished = true
        lock.unlock()
        if !alreadyFinished { handler(content) }
    }
}

private enum NativeNotifyImage {
    /// The https image URL Native Notify puts in the push (`image`; Firebase's `fcm_options.image` is read too).
    static func url(in userInfo: [AnyHashable: Any]) -> URL? {
        var text = userInfo["image"] as? String
        if text == nil, let options = userInfo["fcm_options"] as? [String: Any] { text = options["image"] as? String }
        guard let value = text, let url = URL(string: value), url.scheme?.lowercased() == "https" else { return nil }
        return url
    }

    /// Attaches the downloaded file — only when the server really answered with a JPEG, PNG or GIF (an error page is never attached).
    static func attachment(from location: URL, response: URLResponse?, url: URL) -> UNNotificationAttachment? {
        if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { return nil }
        guard let ext = fileExtension(mimeType: response?.mimeType, url: url) else { return nil }
        let name = "\(UUID().uuidString).\(ext)" // iOS reads the image type from the file extension
        let destination = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(name)
        do {
            try FileManager.default.moveItem(at: location, to: destination)
            return try UNNotificationAttachment(identifier: "native-notify-image", url: destination, options: nil)
        } catch {
            return nil
        }
    }

    /// png, jpg or gif — from what the server says the file is (the URL's own ending only when the server says nothing useful). nil for anything else.
    private static func fileExtension(mimeType: String?, url: URL) -> String? {
        switch mimeType?.lowercased() {
        case "image/png": return "png"
        case "image/gif": return "gif"
        case "image/jpeg", "image/jpg", "image/pjpeg": return "jpg"
        case nil, "application/octet-stream", "binary/octet-stream":
            let ext = url.pathExtension.lowercased()
            if ext == "jpeg" { return "jpg" }
            return ["png", "jpg", "gif"].contains(ext) ? ext : nil
        default: return nil
        }
    }
}

Run your app's scheme (not the extension's) on an iPhone, then send a push with a bigPictureURL.

RuleWhy
The extension's bundle identifier starts with your app's, like com.yourname.app.NotificationService.Xcode refuses to embed one that doesn't. Xcode sets it when it creates the target; change both together if you rename your app's.
Keep the extension's version equal to the app's.App Store Connect rejects a mismatch.
Send a JPEG, PNG or GIF over https.The extension skips anything else and the push arrives without the picture.

The iPhone Simulator never runs notification service extensions, so it shows the text without the picture.

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.