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.MyAppcannot 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
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
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.
- In Xcode, choose File → New → Target → Notification Service Extension, name it
NotificationService, and answer Cancel to "Activate scheme". - Select the new target. Under Signing & Capabilities pick the same Team as your app, and under General set Minimum Deployments to your app's.
- Replace the Swift file Xcode created with:
// 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.
| Rule | Why |
|---|---|
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.