Skip to main content

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

1. Xcode Project Setup

Enable Push Notifications Capability

  1. Open your project in Xcode
  2. Select your app target → Signing & Capabilities
  3. Click + Capability
  4. Add Push Notifications

This adds the aps-environment entitlement to your provisioning profile.

Enable Background Modes (Optional)

If you need silent/background push notifications:

  1. In Signing & Capabilities, click + Capability
  2. Add Background Modes
  3. 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

  1. Go to Apple Developer → Keys
  2. Click + to create a new key
  3. Name it (e.g., "Zyphr Push Key")
  4. Check Apple Push Notifications service (APNs)
  5. Click ContinueRegister
  6. Download the .p8 file (you can only download it once)
  7. 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
}
StatusMeaning
.notDeterminedUser hasn't been asked yet
.authorizedPermission granted
.deniedPermission denied (user must enable in Settings)
.provisionalProvisional 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)")
}
}
SwiftUI Apps

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 StateBehavior
ForegroundwillPresent is called. Return display options or handle silently.
BackgroundSystem displays the notification. didReceive is called on tap.
TerminatedSystem displays the notification. didReceive is called on tap when app launches.

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

  1. In Xcode: File → New → Target
  2. Select Notification Service Extension
  3. 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',
},
});
warning

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 TypeWhen UsedBehavior
alertStandard visible notificationDisplays banner, plays sound, updates badge
backgroundSilent/content-available pushWakes app in background, no UI
voipVoIP call notificationsHigh priority, requires PushKit and CallKit

Zyphr automatically sets the correct push type based on your request:

  • If title or body is present → alert
  • If only content_available: true with databackground
info

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

IssueCauseSolution
No token receivedPush capability not enabledCheck Xcode → Signing & Capabilities
Token works in dev, fails in prodWrong APNs environmentSet Production flag correctly in Zyphr provider config
Notifications not showingPermission deniedCheck UNAuthorizationStatus and prompt user
Silent push not waking appBackground Modes not enabledAdd Remote notifications background mode
Images not displayingMissing Notification Service ExtensionCreate the extension target (see section 8)
Delayed deliveryLow priority or throttledAPNs 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