A Complete Guide to Setting Up Push Notifications on iOS

Push notifications are the primary channel for re-engaging users and delivering time-sensitive information outside your app. On iOS, every remote notification flows through the Apple Push Notification service (APNs) β€” Apple’s infrastructure that relays messages from your server to the user’s device.

This guide covers the complete lifecycle: configuring credentials in the developer portal, registering for device tokens, handling permissions, constructing payloads, implementing rich-media extensions, managing Live Activities, and deploying to production.


Architecture Overview

Push notifications involve two separate phases: registration (app β†’ server) and delivery (server β†’ APNs β†’ device).

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                        REGISTRATION FLOW                            β”‚
β”‚                                                                     β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   registerForRemote()    β”Œβ”€β”€β”€β”€β”€β”€β”   device token      β”‚
β”‚  β”‚  Your    β”‚ ──────────────────────►  β”‚ APNs β”‚ ──────────────────► β”‚
β”‚  β”‚  App     β”‚ ◀──────────────────────  β”‚      β”‚                     β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   didRegisterWithToken   β””β”€β”€β”€β”€β”€β”€β”˜                     β”‚
β”‚       β”‚                                                             β”‚
β”‚       β”‚  POST /register-device (token + user_id)                    β”‚
β”‚       β–Ό                                                             β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                                                       β”‚
β”‚  β”‚  Your    β”‚   stores token in database                            β”‚
β”‚  β”‚  Server  β”‚                                                       β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                                                       β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                         DELIVERY FLOW                               β”‚
β”‚                                                                     β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   HTTP/2 POST /3/device/<token>   β”Œβ”€β”€β”€β”€β”€β”€β”            β”‚
β”‚  β”‚  Your    β”‚ ────── JWT + JSON payload ──────► β”‚ APNs β”‚            β”‚
β”‚  β”‚  Server  β”‚ ◀─────── 200 (accepted) ───────── β”‚      β”‚            β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                                   β””β”€β”€β”€β”€β”€β”€β”˜            β”‚
β”‚                                                    β”‚                β”‚
β”‚                                     persistent TLS β”‚ connection     β”‚
β”‚                                                    β–Ό                β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                                β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”           β”‚
β”‚  β”‚  Your    β”‚ ◀───── notification data ───── β”‚  Device  β”‚           β”‚
β”‚  β”‚  App     β”‚                                β”‚  (iOS)   β”‚           β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                                β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜           β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

The numbered steps:

  1. Your app calls registerForRemoteNotifications().
  2. iOS contacts APNs and receives a device token (unique per app + device + environment).
  3. Your app forwards the token to your server (e.g., POST /register-device).
  4. Your server sends an HTTP/2 POST request to APNs with the device token and a JSON payload, authenticated via a JWT.
  5. APNs routes the notification to the device over its persistent TLS connection β€” no polling required.
  6. iOS displays the banner, plays the sound, or wakes your app in the background.

APNs is reliable but not transactional β€” it stores at most one undelivered notification per device token. Never assume delivery, and plan for it idempotently.


Prerequisites

  • An active Apple Developer Program membership ($99/year).
  • A physical iOS device. Remote notifications do not work on the simulator (except for .apns file drag-and-drop on Apple Silicon Macs with Xcode 14+).
  • Xcode 15+.
  • A server or backend capable of HTTP/2 requests.

1. Apple Developer Portal Setup

1.1 Register an App ID

In the Apple Developer Portal, navigate to Certificates, Identifiers & Profiles > Identifiers. Create or select an App ID matching your app’s bundle identifier. Enable the Push Notifications capability.

1.2 Generate an APNs Auth Key

Apple recommends token-based authentication (.p8) for all new projects. It never expires, works across all apps in your account, and serves both sandbox and production environments.

  1. Go to Keys in the portal and click +.

  2. Name the key (e.g., β€œAPNs Production Key”) and check Apple Push Notifications service (APNs).

  3. Click Register, then Download the .p8 file.

    Apple only lets you download this file once. Store it securely β€” never commit it to version control.

  4. Note the Key ID (10-character alphanumeric string) and your Team ID (visible in Membership details).

If you maintain legacy infrastructure, you can use certificate-based authentication (.p12) instead. These expire annually and require separate certificates for development and production.


2. Xcode Configuration

  1. Open your project and select the app target.
  2. Go to Signing & Capabilities.
  3. Click + Capability and add Push Notifications. This adds the aps-environment entitlement to your App ID and provisioning profile.
  4. (Optional) Add Background Modes and check Remote notifications if you need silent push or background data updates.

3. Requesting Notification Permission

Before you can show alerts or badges, the user must explicitly grant permission. Use the UserNotifications framework.

3.1 Standard Permission Request

The async/await API shown below requires iOS 17+. On earlier versions, use the completion-handler variant (requestAuthorization(options:completionHandler:)).

import UserNotifications

let center = UNUserNotificationCenter.current()

do {
    let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge])
    if granted {
        UIApplication.shared.registerForRemoteNotifications()
    }
} catch {
    print("Permission error: \(error)")
}

Call registerForRemoteNotifications() after the user grants permission. This triggers the APNs registration that produces a device token.

3.2 Provisional Authorization (iOS 12+)

Provisional authorization delivers notifications silently to Notification Center without a prompt. Users can later opt-in to alerts via a β€œKeep” button on the notification.

let granted = try await center.requestAuthorization(
    options: [.alert, .sound, .badge, .provisional]
)

This is useful for apps that want to demonstrate notification value before asking for full access. You can upgrade from provisional to full authorization later.

3.3 Ephemeral Authorization (App Clips)

App Clips use .ephemeral to grant short-lived notification permission without a persistent prompt.

3.4 When to Ask

Do not request permission on launch. Show a pre-permission screen explaining the value first, then ask after a meaningful action (e.g., completing a purchase, receiving a message).


4. Device Token Registration

Implement these methods in your AppDelegate to receive and forward the device 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
        return true
    }

    func application(
        _ application: UIApplication,
        didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
    ) {
        let tokenString = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
        forwardTokenToServer(tokenString)
    }

    func application(
        _ application: UIApplication,
        didFailToRegisterForRemoteNotificationsWithError error: Error
    ) {
        print("Failed to register: \(error.localizedDescription)")
    }

    private func forwardTokenToServer(_ token: String) {
        // POST to your server endpoint
        var request = URLRequest(url: URL(string: "https://api.example.com/devices")!)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        let body = ["deviceToken": token, "platform": "ios"]
        request.httpBody = try? JSONSerialization.data(withJSONObject: body)

        URLSession.shared.dataTask(with: request).resume()
    }
}

Token management rules:

  • Call registerForRemoteNotifications() on every launch. APNs may issue a new token after an OS update, restore from backup, or reinstall.
  • Never cache tokens locally as the single source of truth β€” always rely on your backend database.
  • Tokens are environment-specific. A token from a debug build (sandbox) will not work with the production APNs endpoint, and vice versa.
  • When APNs returns a 410 Gone or 400 BadDeviceToken, immediately remove that token from your database.

5. APNs Authentication

Your server authenticates with APNs using one of two methods.

5.1 Token-Based (Recommended)

The server generates a JSON Web Token (JWT) signed with your .p8 private key. The JWT has a maximum lifetime of one hour; the server should regenerate it periodically.

JWT header:

{
  "alg": "ES256",
  "kid": "ABC123DEFG"
}

JWT payload:

{
  "iss": "TEAMID1234",
  "iat": 1680000000
}

The same .p8 key works for both sandbox and production β€” the server only needs to switch the APNs endpoint URL.

5.2 Certificate-Based (Legacy)

A .p12 certificate is tied to a specific App ID and environment. Requires separate certificates for development and production. Expires annually.

Always use token-based auth for new projects.


6. APNs Endpoints

Environment Endpoint
Sandbox https://api.sandbox.push.apple.com/3/device/
Production https://api.push.apple.com/3/device/

Use the sandbox endpoint for debug builds installed via Xcode. Use production for TestFlight and App Store builds.


7. Constructing the Payload

The payload is a JSON dictionary with an aps key at the root.

7.1 Standard Alert

{
  "aps": {
    "alert": {
      "title": "New Message",
      "body": "You have a new message from Alex."
    },
    "badge": 3,
    "sound": "default"
  },
  "customData": {
    "conversationId": "abc123"
  }
}

7.2 Required APNs Headers

Header Value
apns-topic Your app’s bundle ID
apns-push-type alert, background, liveactivity, voip, complication
apns-priority 10 (immediate) or 5 (power-friendly). 10 must trigger an alert, sound, or badge β€” it is an error to use 10 for a silent push (content-available only)
apns-expiration Unix epoch seconds; 0 for immediate expiration
authorization bearer <JWT>

7.3 Silent Push (Content Available)

No visible UI β€” wakes the app to fetch data in the background.

{
  "aps": {
    "content-available": 1
  }
}

Silent pushes are aggressively rate-limited by iOS. Use them sparingly β€” consider BackgroundTasks framework for periodic updates.

7.4 Push Types

  • alert β€” Standard user-visible notification with title, body, sound, badge.
  • background β€” No UI. Sets content-available: 1. Wakes app to refresh data.
  • liveactivity β€” Updates a running Live Activity’s content state.
  • voip β€” For VoIP apps (requires VoIP entitlement).
  • complication β€” For watchOS complication updates.

8. Handling Notifications In-App

Implement UNUserNotificationCenterDelegate to control notification presentation and handle taps. The async delegate methods below require iOS 17+ β€” use the completion-handler variants for earlier targets.

extension AppDelegate: UNUserNotificationCenterDelegate {

    // Called when a notification arrives while the app is in the foreground.
    // Return the presentation options you want (default is none).
    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        willPresent notification: UNNotification
    ) async -> UNNotificationPresentationOptions {
        return [.banner, .sound, .badge]
    }

    // Called when the user taps or responds to a notification.
    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        didReceive response: UNNotificationResponse
    ) async {
        let userInfo = response.notification.request.content.userInfo
        handleNotification(userInfo)
    }

    private func handleNotification(_ userInfo: [AnyHashable: Any]) {
        guard let conversationId = userInfo["conversationId"] as? String else { return }
        // Navigate to the relevant screen
    }
}

Setting the Delegate

Set the delegate early, before the app finishes launching, to avoid missing notifications:

UNUserNotificationCenter.current().delegate = self

9. Rich Notifications (Notification Service Extension)

A Notification Service Extension intercepts remote notifications before they are displayed, giving you up to 30 seconds to modify the payload β€” download images, decrypt content, or localize text.

9.1 Add the Extension Target

  1. In Xcode: File > New > Target > Notification Service Extension.
  2. Name it (e.g., NotificationService). Xcode generates a NotificationService.swift file.

9.2 The Extension Code

The extension triggers only when the payload includes "mutable-content": 1.

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 bestAttemptContent else {
            contentHandler(request.content)
            return
        }

        // Download and attach an image
        guard let urlString = request.content.userInfo["image_url"] as? String,
              let url = URL(string: urlString) else {
            contentHandler(bestAttemptContent)
            return
        }

        let task = URLSession.shared.downloadTask(with: url) { localURL, _, error in
            guard let localURL, error == nil else {
                contentHandler(bestAttemptContent)
                return
            }

            let ext = url.pathExtension.isEmpty ? "jpg" : url.pathExtension
            let savedURL = FileManager.default.temporaryDirectory
                .appendingPathComponent(UUID().uuidString + "." + ext)

            do {
                try FileManager.default.moveItem(at: localURL, to: savedURL)
                let attachment = try UNNotificationAttachment(
                    identifier: "image",
                    url: savedURL,
                    options: nil
                )
                bestAttemptContent.attachments = [attachment]
            } catch {
                // Deliver without attachment
            }

            contentHandler(bestAttemptContent)
        }
        task.resume()
    }

    override func serviceExtensionTimeWillExpire() {
        // Called when the 30-second budget is nearly exhausted.
        if let contentHandler, let bestAttemptContent {
            contentHandler(bestAttemptContent)
        }
    }
}

9.3 Trigger Payload

{
  "aps": {
    "alert": {
      "title": "Special Offer",
      "body": "Check out our latest deals!"
    },
    "mutable-content": 1
  },
  "image_url": "https://example.com/promo.jpg"
}

UNNotificationAttachment supports images, GIFs, video, and audio. The file extension determines the inferred media type β€” CDN URLs with query strings often need explicit extension handling.

9.4 Important Notes

  • The extension’s bundle ID must be a child of the main app’s bundle ID (e.g., com.app.Main β†’ com.app.Main.NotificationService).
  • The mutable-content key is required β€” without it the extension never runs.
  • Silent pushes (content-available: 1 without an alert dictionary) will not trigger the extension.
  • Always call contentHandler β€” if you don’t, the system displays the original payload after a timeout.

10. Notification Categories and Actions

Define interactive notification buttons using UNNotificationCategory and register them at launch.

// Register categories early in didFinishLaunching
let acceptAction = UNNotificationAction(
    identifier: "ACCEPT",
    title: "Accept",
    options: .foreground
)
let declineAction = UNNotificationAction(
    identifier: "DECLINE",
    title: "Decline",
    options: .destructive
)
let category = UNNotificationCategory(
    identifier: "FRIEND_REQUEST",
    actions: [acceptAction, declineAction],
    intentIdentifiers: [],
    options: .customDismissAction
)

UNUserNotificationCenter.current().setNotificationCategories([category])

Server sends the category key in the aps payload:

{
  "aps": {
    "alert": { "title": "Friend Request", "body": "Alice wants to be your friend" },
    "category": "FRIEND_REQUEST"
  }
}

Handle the response in the delegate:

func userNotificationCenter(
    _ center: UNUserNotificationCenter,
    didReceive response: UNNotificationResponse
) async {
    switch response.actionIdentifier {
    case "ACCEPT":
        // Accept friend request
    case "DECLINE":
        // Decline
    case UNNotificationDefaultActionIdentifier:
        // User tapped the notification body
    default:
        break
    }
}

11. Live Activities

Live Activities are persistent, glanceable widgets on the Lock Screen and Dynamic Island that update in place for ongoing events. They are not push notifications, but can be updated via APNs.

11.1 Setup

  1. Add a Widget Extension target to your app.
  2. Define an ActivityAttributes struct with a nested ContentState.
  3. Declare SwiftUI views in an ActivityConfiguration.
  4. In your app, request the activity with Activity.request(...).

11.2 Push Updates

To update a Live Activity from your server, use the liveactivity push type:

apns-push-type: liveactivity
apns-topic: <bundle-id>.push-type.liveactivity

11.3 Broadcast Push Notifications (iOS 18+)

With iOS 18, you can send a single push notification to update thousands of Live Activities at once via a channel. No need to store individual push tokens.

Channel: Your Server ──single POST──► APNs ──► All Subscribed Devices

Use the Push Notifications Console to test integration.


12. Provisional to Full Authorization Upgrade

If you started with provisional authorization, you can request full authorization later β€” the system shows the prompt again.

func upgradeToFullAuthorization() async -> Bool {
    let center = UNUserNotificationCenter.current()
    let settings = await center.notificationSettings()

    guard settings.authorizationStatus == .provisional else {
        return settings.authorizationStatus == .authorized
    }

    do {
        let granted = try await center.requestAuthorization(
            options: [.alert, .sound, .badge]
        )
        return granted
    } catch {
        return false
    }
}

13. iOS 18+ Updates

Priority Notifications

iOS 18 introduced Apple Intelligence-powered Priority Notifications that surface important alerts at the top of the Notification Center. There is no opt-in mechanism for apps β€” the system determines priority based on content.

Live Activity Update Frequency

iOS 18 reduced Live Activity update frequency from every 1 second to every 5–15 seconds. Most use cases (delivery, ride-sharing, sports scores) are unaffected. Second-by-second use cases (fitness tracking, stock tickers) need alternative approaches.

Broadcast Push

As announced at WWDC 2024, broadcast push notifications let you update all Live Activities for an event with a single APNs request. Manage channel lifecycles via the channel management API β€” delete channels when events end.


14. Testing Push Notifications

14.1 Simulator (Apple Silicon)

On Apple Silicon Macs running Xcode 14+, create a .apns file and drag it onto the simulator:

{
  "Simulator Target Bundle": "com.example.app",
  "aps": {
    "alert": { "title": "Test", "body": "Hello from simulator" },
    "sound": "default"
  }
}

14.2 Command Line

xcrun simctl push booted com.example.app payload.json

14.3 Curl (Real Device)

Generate a JWT signed with your .p8 key first (it expires after one hour). Use openssl with base64 URL-safe encoding:

# Set your values
AUTH_KEY_ID="ABC123DEFG"
TEAM_ID="TEAMID1234"
AUTH_KEY="/path/to/AuthKey_ABC123DEFG.p8"

# Build JWT components
HEADER=$(printf '{ "alg": "ES256", "kid": "%s" }' "$AUTH_KEY_ID" \
  | openssl base64 -e -A | tr '+/' '-_' | tr -d =)
CLAIMS=$(printf '{ "iss": "%s", "iat": %d }' "$TEAM_ID" "$(date +%s)" \
  | openssl base64 -e -A | tr '+/' '-_' | tr -d =)
JWT_HEADER_CLAIMS="${HEADER}.${CLAIMS}"
SIGNATURE=$(printf "$JWT_HEADER_CLAIMS" \
  | openssl dgst -binary -sha256 -sign "$AUTH_KEY" \
  | openssl base64 -e -A | tr '+/' '-_' | tr -d =)
JWT_TOKEN="${HEADER}.${CLAIMS}.${SIGNATURE}"

Then send the push:

curl -v --header "apns-topic: com.example.app" \
     --header "apns-push-type: alert" \
     --header "authorization: bearer $JWT_TOKEN" \
     --data '{"aps":{"alert":"Test"}}' \
     --http2 \
     https://api.sandbox.push.apple.com/3/device/$DEVICE_TOKEN

14.4 Push Notifications Console

Apple’s Push Notifications Console lets you compose and send test pushes without writing backend code.


15. Common Issues and Troubleshooting

Symptom Likely Cause Fix
Notification never arrives Wrong APNs endpoint (sandbox vs production) Match endpoint to build type
BadDeviceToken (400) Token from wrong environment or expired Re-register on launch; verify endpoint match
TopicDisallowed (400) Bundle ID mismatch Verify apns-topic matches App ID
ExpiredProviderToken (403) JWT timestamp older than 1 hour Refresh JWT periodically
PayloadTooLarge (400) Payload exceeds 4 KB Compress or use mutable-content to fetch large data
No token generated Missing capability or provisioning Re-check Push Notifications entitlement
Extension not triggered Missing mutable-content: 1 Add the flag to the payload
Background push ignored No Remote notifications background mode Enable Background Modes > Remote notifications

16. Best Practices

  • Ask permission in context β€” never on first launch. Show a pre-permission screen explaining value, then request after a meaningful interaction.
  • Register on every launch β€” tokens can change at any time.
  • Use provisional authorization for non-critical apps to demonstrate value before asking for full alerts.
  • Always use token-based auth (.p8) for new projects. It never expires, works across environments, and covers all your apps.
  • Keep payloads under 4 KB for reliable delivery.
  • Use apns-priority: 5 for non-urgent notifications to preserve battery and avoid throttling.
  • Handle token invalidation β€” remove 410 Gone tokens from your database immediately.
  • Separate sandbox and production tokens β€” a sandbox token will not work on production.
  • Deep-link from every notification β€” every tap should take the user to relevant content.
  • Set the delegate early β€” before didFinishLaunchingWithOptions returns, to catch notifications the app might receive at launch.

17. Putting It All Together

A production-ready notification flow:

  1. App launch: Register notification categories, set delegate, check authorization status. If .notDetermined, show pre-permission UI.
  2. After user action: Request authorization with .provisional or .alert/.sound/.badge.
  3. Token received: POST device token to your server along with user ID and environment flag.
  4. Server stores token: In a secure database, associated with the user.
  5. Server sends push: Generate JWT, construct payload with deep-link data, POST to APNs.
  6. App receives push: NSE (if mutable-content: 1) downloads media. Delegate handles foreground presentation and tap navigation.
  7. Token refresh: registerForRemoteNotifications() on every launch; server updates tokens when they change.
  8. Invalid tokens: Server processes APNs error responses (410, 400) to clean up stale tokens.