Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 036 / 100

Push notifications in iOS: a practical Swift & iOS guide

Reach users with APNs.

Expona Admin8 min read

Push notifications in iOS: a practical Swift & iOS guide

Users abandon apps when they feel forgotten. Push notifications keep your product top-of-mind, but only when implemented thoughtfully—the wrong approach tanks engagement and invites uninstalls. This guide walks through the complete flow: requesting permission at the right moment, collecting device tokens, sending notifications from your backend, and enriching them with images and custom actions.

Why push notifications matter in production

We've seen this pattern across ride-hailing, e-commerce, and SaaS platforms: a well-timed notification drives a 3–5x increase in daily active users compared to in-app messaging alone. The key word is well-timed. A notification about order shipment the moment the package leaves the warehouse converts. A notification at 3 AM about a sale does not.

Apple's push notification infrastructure (APNs) is mature and reliable, handling billions of messages daily. Your job is to integrate it cleanly, respect user preferences, and leverage rich media and actions to make each notification count.

Request permission at a meaningful moment

Never ask for notification permission on app launch. Users instinctively deny. Instead, ask after the user has experienced core value: completed their first order, created their first saved item, or received a real notification they actually want.

Here's how we request permission in production code:

swift
import UserNotifications

func requestNotificationPermission(completion: @escaping (Bool) -> Void) {
    UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
        DispatchQueue.main.async {
            if granted {
                UIApplication.shared.registerForRemoteNotifications()
            }
            completion(granted)
        }
    }
}

The .alert, .sound, and .badge options let you specify exactly what you want to show. If your use case only needs badges (like unread message counts), request only that—it improves your grant rate. Request fewer permissions, and users say yes more often.

Real scenario: An e-commerce app asks for notification permission after the user completes their first purchase. The messaging is: "Get notified when your order ships." Specific, timely, relevant. Compare that to asking on app launch—cold, unclear, denied.

Call this function from a view controller or SwiftUI view at the moment of highest intent:

swift
Button("Enable order tracking") {
    requestNotificationPermission { granted in
        if granted {
            print("Ready to receive notifications")
        }
    }
}

Register for remote notifications and send the device token to your backend

Once permission is granted, iOS automatically calls application(_:didFinishLaunchingWithOptions:) and application(_:didRegisterForRemoteNotificationsWithDeviceToken:) in your AppDelegate. Capture that device token and send it to your backend.

The device token is a unique identifier for that specific app on that specific device. Every token is valid only for your app bundle ID and APNs certificate/key. If the user uninstalls and reinstalls, they get a new token.

swift
func application(
    _ application: UIApplication,
    didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
    let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
    print("Device Token: \(token)")
    
    // Send to your backend immediately
    Task {
        await uploadTokenToBackend(token: token)
    }
}

func application(
    _ application: UIApplication,
    didFailToRegisterForRemoteNotificationsWithError error: Error
) {
    print("Failed to register for remote notifications: \(error)")
}

Real scenario: A ride-hailing platform collects device tokens from drivers. When a rider requests a ride, the backend looks up the driver's current token and sends a push to their device with pickup details. Without the token, the backend has no way to reach the device.

Store tokens securely on your backend, associated with the user's account. You'll likely have multiple tokens per user (phone, tablet, watch). When a user logs out, revoke all their tokens. When a user switches devices, new tokens replace old ones over time as devices register.

Send from server using APNs with token-based authentication

Apple provides two ways to authenticate with APNs: certificate-based (older, being phased out) and token-based (current standard). Token-based is simpler: you generate a JWT using a key file from your Apple Developer account.

Here's a typical backend flow in Node.js, which we've used in production:

swift
// Swift representation of the payload you'd construct on any backend
struct APNsPayload: Codable {
    let aps: APSContent
    
    struct APSContent: Codable {
        let alert: AlertContent?
        let sound: String?
        let badge: Int?
        let "content-available": Int? // For silent notifications
        
        struct AlertContent: Codable {
            let title: String
            let body: String
            let "launch-image": String?
        }
    }
}

// Example payload
let payload = APNsPayload(
    aps: .init(
        alert: .init(
            title: "Your order shipped",
            body: "Track it now",
            launchImage: nil
        ),
        sound: "default",
        badge: 1,
        contentAvailable: nil
    )
)

On your backend, you'll use a JWT token to authenticate with APNs. The process:

  1. Download your Auth Key from Apple Developer (a .p8 file).
  2. Use that key to sign a JWT token (valid for 1 hour).
  3. POST the JSON payload to APNs with the JWT in the Authorization header.
  4. APNs routes the notification to the device's token.

Real scenario: A travel platform sends a notification when a flight is delayed. The backend loops through all passengers on that flight, looks up their device tokens, and sends a rich notification with the new departure time and a "View itinerary" action. The entire process takes milliseconds.

Most teams use libraries to handle this. In Node.js, the apn package abstracts away the JWT complexity. In Python, it's push-notifications. Whatever your stack, avoid building APNs clients from scratch—the JWT signing and HTTP/2 multiplexing are error-prone.

Rich notifications with images and actions

A plain text notification is forgettable. Rich notifications—with images, custom buttons, and rich formatting—engage users and reduce uninstalls. You need two pieces: a notification content extension in your app target and a slightly modified payload on the backend.

Step 1: Create a notification content extension target.

In Xcode: File → New → Target → Notification Content Extension. This gives you a separate SwiftUI or UIKit view that renders your custom notification UI.

Step 2: Implement the extension.

swift
import UserNotifications
import UserNotificationsUI

class NotificationViewController: UIViewController, UNNotificationContentExtension {
    @IBOutlet weak var imageView: UIImageView!
    @IBOutlet weak var titleLabel: UILabel!
    
    func didReceive(_ notification: UNNotification) {
        let content = notification.request.content
        
        titleLabel.text = content.title
        
        // Load the image from the notification's attachment
        if let attachment = content.attachments.first,
           let data = try? Data(contentsOf: attachment.url) {
            imageView.image = UIImage(data: data)
        }
    }
}

Step 3: Send a rich payload from your backend.

Include a mutable-content: 1 flag and an attachment URL:

swift
struct RichAPNsPayload: Codable {
    let aps: APSContent
    let imageUrl: String? // Custom field
    
    struct APSContent: Codable {
        let alert: AlertContent?
        let "mutable-content": Int // Tells iOS to load your extension
        let "category": String? // Links to notification actions
        
        struct AlertContent: Codable {
            let title: String
            let body: String
        }
    }
}

Step 4: Add custom notification actions.

Define actions in your main app's AppDelegate:

swift
func registerNotificationActions() {
    let viewAction = UNNotificationAction(
        identifier: "VIEW_ORDER",
        title: "View Order",
        options: .foreground
    )
    let cancelAction = UNNotificationAction(
        identifier: "CANCEL_ORDER",
        title: "Cancel",
        options: [.destructive]
    )
    
    let category = UNNotificationCategory(
        identifier: "ORDER_CATEGORY",
        actions: [viewAction, cancelAction],
        intentIdentifiers: [],
        options: []
    )
    
    UNUserNotificationCenter.current().setNotificationCategories([category])
}

Then handle the action when the user taps a button:

swift
func userNotificationCenter(
    _ center: UNUserNotificationCenter,
    didReceive response: UNNotificationResponse,
    withCompletionHandler completionHandler: @escaping () -> Void
) {
    let userInfo = response.notification.request.content.userInfo
    
    switch response.actionIdentifier {
    case "VIEW_ORDER":
        if let orderId = userInfo["orderId"] as? String {
            navigateToOrder(orderId)
        }
    case "CANCEL_ORDER":
        if let orderId = userInfo["orderId"] as? String {
            cancelOrder(orderId)
        }
    default:
        // Notification tapped, not an action
        break
    }
    
    completionHandler()
}

Real scenario: A CRM sends a notification to a sales rep: "New lead: Acme Corp." The notification includes a company logo and two actions: "View lead" and "Call now." When the rep taps "Call now," the app opens the phone dialer with the prospect's number pre-filled. When they tap "View lead," the app opens the lead details. Both are fast, friction-free interactions.

Common mistakes

  • Asking for permission too early. You get denied and can't ask again until the user manually enables it in Settings. Ask after the user sees value.
  • Forgetting to handle token expiry. Device tokens are permanent per device, but users uninstall, switch devices, and revoke permissions. Your backend must handle 404 responses from APNs (token no longer valid) and remove dead tokens.
  • Sending notifications without user opt-in per category. Just because you have permission doesn't mean you should flood the user with every notification type. Let users choose which types they want.
  • Not testing with a real device. The simulator doesn't receive real push notifications. Always test on physical hardware, especially for rich notifications and actions.
  • Ignoring quiet hours. iOS respects the user's focus modes and Do Not Disturb settings. Respect them in your backend logic too—don't send urgent transactional notifications at 3 AM unless absolutely necessary, and never send marketing notifications then.
  • Including sensitive data in the payload without encryption. APNs is secure in transit (TLS), but don't send passwords or PII in the notification body. Send an identifier and fetch the full payload when the user opens the app.

Key takeaways

  • Request notification permission at a meaningful moment after the user experiences core value, not on app launch.
  • Capture the device token in didRegisterForRemoteNotificationsWithDeviceToken and send it to your backend immediately, associated with the user account.
  • Use token-based APNs authentication on your backend; it's simpler and more secure than certificates.
  • Implement notification content extensions to display images and custom UI; it dramatically improves engagement.
  • Add custom notification actions to let users take quick actions without opening the app.
  • Always handle token expiry, respect user preferences, and test on real devices.
  • Never send unsolicited notifications—respect focus modes, quiet hours, and user opt-in preferences.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles