Push Notifications for iOS
This guide walks you through the entire iOS push notification integration — from Xcode setup to handling notification taps and deep links.
Prerequisites
- An Apple Developer Program membership
- Xcode 14.0+
- iOS 15.0+ deployment target
- A Zyphr account with push notifications enabled
- APNs provider configured in Zyphr (see Provider Setup)
1. Xcode Project Setup
Enable Push Notifications Capability
- Open your project in Xcode
- Select your app target → Signing & Capabilities
- Click + Capability
- Add Push Notifications
This adds the aps-environment entitlement to your provisioning profile.
Enable Background Modes (Optional)
If you need silent/background push notifications:
- In Signing & Capabilities, click + Capability
- Add Background Modes
- Check Remote notifications
Provisioning Profile
Apple automatically manages push notification entitlements when you enable the capability. Ensure your provisioning profile is up to date:
- Development: Uses APNs sandbox (
gateway.sandbox.push.apple.com) - Distribution: Uses APNs production (
gateway.push.apple.com)
Set the Production flag in your Zyphr APNs provider configuration to match your environment.
2. Create the APNs Auth Key
- Go to Apple Developer → Keys
- Click + to create a new key
- Name it (e.g., "Zyphr Push Key")
- Check Apple Push Notifications service (APNs)
- Click Continue → Register
- Download the
.p8file (you can only download it once) - Note the Key ID (10-character string shown on the key page)
You'll also need your Team ID — find it at the top right of the Apple Developer portal, or under Membership Details.
Configure these in Zyphr under Push → Providers → Apple Push Notification service.
3. Request Notification Permission
Ask the user for permission to display notifications. Best practice is to request at a contextually appropriate moment, not immediately at app launch.
import UserNotifications
func requestNotificationPermission() async -> Bool {
let center = UNUserNotificationCenter.current()
do {
let granted = try await center.requestAuthorization(options: [.alert, .badge, .sound])
if granted {
await MainActor.run {
UIApplication.shared.registerForRemoteNotifications()
}
}
return granted
} catch {
print("Permission request failed: \(error)")
return false
}
}
Check Current Permission Status
func checkNotificationStatus() async -> UNAuthorizationStatus {
let settings = await UNUserNotificationCenter.current().notificationSettings()
return settings.authorizationStatus
}
| Status | Meaning |
|---|---|
.notDetermined | User hasn't been asked yet |
.authorized | Permission granted |
.denied | Permission denied (user must enable in Settings) |
.provisional | Provisional authorization (quiet notifications) |
4. Get the APNs Device Token
After calling registerForRemoteNotifications(), iOS delivers the device token via AppDelegate:
import UIKit
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
// Convert token to hex string
let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
print("APNs token: \(token)")
// Register with Zyphr
Task {
await registerDeviceWithZyphr(token: token)
}
}
func application(
_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error
) {
print("Failed to register: \(error.localizedDescription)")
}
}
If you're using a SwiftUI app lifecycle (no AppDelegate), create one using @UIApplicationDelegateAdaptor:
@main
struct MyApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
5. Register the Device with Zyphr
Using the iOS SDK
import ZyphrSDK
func registerDeviceWithZyphr(token: String) async {
let config = Configuration(apiKey: "zy_live_xxx")
let devices = DevicesAPI(configuration: config)
do {
try await devices.registerDevice(
registerDeviceRequest: RegisterDeviceRequest(
subscriberId: "user_123", // Your user's ID in Zyphr
token: token,
platform: .ios,
appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String,
osVersion: UIDevice.current.systemVersion
)
)
print("Device registered with Zyphr")
} catch {
print("Registration failed: \(error)")
}
}
Using the REST API
func registerDeviceWithZyphr(token: String) async {
var request = URLRequest(url: URL(string: "https://api.zyphr.dev/v1/devices")!)
request.httpMethod = "POST"
request.setValue("zy_live_xxx", forHTTPHeaderField: "X-API-Key")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body: [String: Any] = [
"user_id": "user_123",
"token": token,
"platform": "ios",
"app_version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "",
"os_version": UIDevice.current.systemVersion
]
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
do {
let (_, response) = try await URLSession.shared.data(for: request)
let httpResponse = response as? HTTPURLResponse
print("Registration status: \(httpResponse?.statusCode ?? 0)")
} catch {
print("Registration failed: \(error)")
}
}
6. Handle Incoming Notifications
Set Up the Notification Delegate
Configure UNUserNotificationCenterDelegate to handle notifications in both foreground and background states.
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
UNUserNotificationCenter.current().delegate = self
return true
}
// Called when notification arrives while app is in foreground
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification
) async -> UNNotificationPresentationOptions {
let userInfo = notification.request.content.userInfo
print("Foreground notification: \(userInfo)")
// Show banner + sound even when app is in foreground
return [.banner, .sound, .badge]
}
// Called when user taps a notification
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse
) async {
let userInfo = response.notification.request.content.userInfo
handleNotificationTap(userInfo: userInfo, actionIdentifier: response.actionIdentifier)
}
}
Foreground vs Background Behavior
| App State | Behavior |
|---|---|
| Foreground | willPresent is called. Return display options or handle silently. |
| Background | System displays the notification. didReceive is called on tap. |
| Terminated | System displays the notification. didReceive is called on tap when app launches. |
7. Handle Notification Taps & Deep Links
Parse the notification payload to navigate the user to the right screen:
func handleNotificationTap(userInfo: [AnyHashable: Any], actionIdentifier: String) {
// Extract custom data from the Zyphr payload
guard let data = userInfo["data"] as? [String: Any] else { return }
// Handle action buttons
switch actionIdentifier {
case "VIEW_ACTION":
// User tapped "View" action button
break
case "DISMISS_ACTION":
// User tapped "Dismiss" action button
return
case UNNotificationDefaultActionIdentifier:
// User tapped the notification itself
break
default:
break
}
// Deep link based on notification data
if let type = data["type"] as? String {
switch type {
case "message":
let messageId = data["message_id"] as? String ?? ""
navigateToMessage(id: messageId)
case "order":
let orderId = data["order_id"] as? String ?? ""
navigateToOrder(id: orderId)
default:
break
}
}
}
Deep Linking with SwiftUI
class NotificationRouter: ObservableObject {
@Published var activeDeepLink: DeepLink?
enum DeepLink: Equatable {
case message(id: String)
case order(id: String)
}
func handle(userInfo: [AnyHashable: Any]) {
guard let data = userInfo["data"] as? [String: Any],
let type = data["type"] as? String else { return }
switch type {
case "message":
activeDeepLink = .message(id: data["message_id"] as? String ?? "")
case "order":
activeDeepLink = .order(id: data["order_id"] as? String ?? "")
default:
break
}
}
}
8. Rich Notifications
Images
Zyphr sends image URLs via the image_url parameter. To display images in iOS notifications, you need a Notification Service Extension.
Create the Extension
- In Xcode: File → New → Target
- Select Notification Service Extension
- Name it (e.g.,
NotificationService)
Download and Attach Images
import UserNotifications
class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent?
override func didReceive(
_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
) {
self.contentHandler = contentHandler
bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent
guard let content = bestAttemptContent,
let imageURLString = content.userInfo["image_url"] as? String,
let imageURL = URL(string: imageURLString) else {
contentHandler(request.content)
return
}
downloadImage(from: imageURL) { attachment in
if let attachment = attachment {
content.attachments = [attachment]
}
contentHandler(content)
}
}
private func downloadImage(from url: URL, completion: @escaping (UNNotificationAttachment?) -> Void) {
URLSession.shared.downloadTask(with: url) { localURL, _, error in
guard let localURL = localURL, error == nil else {
completion(nil)
return
}
let tempDir = FileManager.default.temporaryDirectory
let fileName = url.lastPathComponent
let tempFile = tempDir.appendingPathComponent(fileName)
try? FileManager.default.moveItem(at: localURL, to: tempFile)
let attachment = try? UNNotificationAttachment(identifier: "image", url: tempFile)
completion(attachment)
}.resume()
}
override func serviceExtensionTimeWillExpire() {
if let contentHandler = contentHandler, let content = bestAttemptContent {
contentHandler(content)
}
}
}
Action Buttons
Register notification categories with action buttons at app startup:
func registerNotificationCategories() {
let viewAction = UNNotificationAction(
identifier: "VIEW_ACTION",
title: "View",
options: [.foreground]
)
let dismissAction = UNNotificationAction(
identifier: "DISMISS_ACTION",
title: "Dismiss",
options: [.destructive]
)
let messageCategory = UNNotificationCategory(
identifier: "MESSAGE",
actions: [viewAction, dismissAction],
intentIdentifiers: [],
options: []
)
UNUserNotificationCenter.current().setNotificationCategories([messageCategory])
}
When sending from Zyphr, include action buttons:
await zyphr.push.send({
user_id: 'user_123',
title: 'New Message',
body: 'You have a new message from John',
action_buttons: [
{ id: 'VIEW_ACTION', title: 'View' },
{ id: 'DISMISS_ACTION', title: 'Dismiss' },
],
});
9. Silent / Background Push
Silent push notifications wake your app in the background without displaying anything to the user. Use these for data sync, content prefetch, or updating app state.
// In AppDelegate
func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any]
) async -> UIBackgroundFetchResult {
guard let data = userInfo["data"] as? [String: Any] else {
return .noData
}
// Perform background work
let type = data["type"] as? String
switch type {
case "sync":
await syncData()
return .newData
case "badge_update":
let count = data["count"] as? Int ?? 0
await MainActor.run {
UIApplication.shared.applicationIconBadgeNumber = count
}
return .newData
default:
return .noData
}
}
Send a silent push from Zyphr:
await zyphr.push.send({
user_id: 'user_123',
content_available: true,
data: {
type: 'sync',
resource: 'messages',
},
});
iOS limits background execution time to ~30 seconds. Don't start long-running tasks in silent push handlers. iOS may also throttle silent push delivery if your app uses too much background time.
10. APNs Push Types
APNs supports different push types that affect delivery priority and behavior:
| Push Type | When Used | Behavior |
|---|---|---|
alert | Standard visible notification | Displays banner, plays sound, updates badge |
background | Silent/content-available push | Wakes app in background, no UI |
voip | VoIP call notifications | High priority, requires PushKit and CallKit |
Zyphr automatically sets the correct push type based on your request:
- If
titleorbodyis present →alert - If only
content_available: truewithdata→background
VoIP push requires a separate VoIP certificate and PushKit integration. Contact support if you need VoIP push delivery.
11. Badge Management
Set Badge Count
// Set badge to specific number
await zyphr.push.send({
user_id: 'user_123',
title: 'New Message',
body: '3 unread messages',
badge: 3,
});
Clear Badge
Clear the badge when the user opens the app:
func applicationDidBecomeActive(_ application: UIApplication) {
application.applicationIconBadgeNumber = 0
}
12. Token Refresh
APNs device tokens can change. Re-register the token whenever iOS provides a new one:
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
// Always send the latest token — Zyphr handles deduplication
Task {
await registerDeviceWithZyphr(token: token)
}
}
Zyphr automatically deactivates stale tokens when APNs reports them as invalid.
Troubleshooting
| Issue | Cause | Solution |
|---|---|---|
| No token received | Push capability not enabled | Check Xcode → Signing & Capabilities |
| Token works in dev, fails in prod | Wrong APNs environment | Set Production flag correctly in Zyphr provider config |
| Notifications not showing | Permission denied | Check UNAuthorizationStatus and prompt user |
| Silent push not waking app | Background Modes not enabled | Add Remote notifications background mode |
| Images not displaying | Missing Notification Service Extension | Create the extension target (see section 8) |
| Delayed delivery | Low priority or throttled | APNs may delay background type pushes |
Complete Example
A minimal but complete integration:
import UIKit
import UserNotifications
import ZyphrSDK
@main
struct MyApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
let zyphrConfig = Configuration(apiKey: "zy_live_xxx")
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
UNUserNotificationCenter.current().delegate = self
registerNotificationCategories()
return true
}
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
Task {
let devices = DevicesAPI(configuration: zyphrConfig)
try? await devices.registerDevice(
registerDeviceRequest: RegisterDeviceRequest(
subscriberId: currentUserId(),
token: token,
platform: .ios
)
)
}
}
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification
) async -> UNNotificationPresentationOptions {
return [.banner, .sound, .badge]
}
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse
) async {
let userInfo = response.notification.request.content.userInfo
handleNotificationTap(userInfo: userInfo, actionIdentifier: response.actionIdentifier)
}
}
Next Steps
- Push Notifications API Reference — Full API parameters and options
- iOS SDK Reference — Complete SDK documentation
- Device Management — Managing registered devices
- Topics — Subscribe devices to notification topics