Push notifications are most useful when they take the user exactly where they need to go. When combined with a solid push notification deep link strategy, they become one of the most effective tools for driving in-app engagement. A notification about a new order confirmation that drops the user on the app home screen is a missed opportunity. One that opens the order detail page directly is the experience users expect.
Universal Links make this straightforward on iOS. Because Universal Links are standard HTTPS URLs, you can embed them directly in your APNs payload and then route the user to the right screen when they tap. This guide walks through every part of that setup: the payload structure, the delegate methods, screen routing, and handling all three app states.
If you need a primer on Universal Links themselves before continuing, our guide on everything you need to know about Universal Links covers the fundamentals, or the Tolinku Universal Links documentation covers the apple-app-site-association file, entitlements, and domain setup.
The APNs Payload
Apple Push Notification service (APNs) accepts a JSON payload with your notification content. The simplest way to attach a deep link is to add a custom key at the top level of the payload alongside the aps dictionary.
{
"aps": {
"alert": {
"title": "Your order has shipped",
"body": "Order #4821 is on its way. Tap to track."
},
"badge": 1,
"sound": "default"
},
"deep_link": "https://shop.example.com/orders/4821"
}
The deep_link key is custom. You choose the name. Whatever you pick, use it consistently across your backend and your app. Some teams use url, others link or destination. Pick one and document it.
The value is a full Universal Link URL. When your app receives this notification, it reads deep_link from the payload and routes accordingly.
Rich Notifications with Deep Links
For media-rich notifications (image or video attachments), the structure is the same. The deep link rides along as a custom key regardless of what else is in the payload:
{
"aps": {
"alert": {
"title": "Flash sale starts now",
"body": "Up to 40% off running shoes. Limited time."
},
"mutable-content": 1,
"sound": "default"
},
"deep_link": "https://shop.example.com/sale/running-shoes",
"image_url": "https://cdn.example.com/banners/running-sale.jpg"
}
The mutable-content: 1 flag tells iOS to deliver the notification to your Notification Service Extension before displaying it, which is where you would download and attach the image. Your extension can also read the deep_link key at this stage if needed, though typically you just pass it through untouched.
Action Buttons with Different Destinations
Notification categories let you add action buttons. Each button can route to a different screen by carrying its own URL in the payload:
{
"aps": {
"alert": {
"title": "New message from Sarah",
"body": "Hey, are you coming to the event tonight?"
},
"category": "MESSAGE",
"sound": "default"
},
"deep_link": "https://app.example.com/conversations/sarah-82",
"reply_link": "https://app.example.com/conversations/sarah-82/reply",
"profile_link": "https://app.example.com/users/sarah-82"
}
You register the MESSAGE category with its actions at app launch:
let replyAction = UNNotificationAction(
identifier: "REPLY_ACTION",
title: "Reply",
options: [.foreground]
)
let viewProfileAction = UNNotificationAction(
identifier: "VIEW_PROFILE",
title: "View Profile",
options: [.foreground]
)
let messageCategory = UNNotificationCategory(
identifier: "MESSAGE",
actions: [replyAction, viewProfileAction],
intentIdentifiers: [],
options: []
)
UNUserNotificationCenter.current().setNotificationCategories([messageCategory])
When the user taps an action, you read the matching key from the payload to get the correct destination URL.
Handling Notification Taps
Now for the code that responds to taps. You need to implement UNUserNotificationCenterDelegate. The two methods that matter are userNotificationCenter(_:didReceive:withCompletionHandler:) for background and killed state taps, and userNotificationCenter(_:willPresent:withCompletionHandler:) for foreground delivery.
AppDelegate Setup
import UIKit
import UserNotifications
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
UNUserNotificationCenter.current().delegate = self
UNUserNotificationCenter.current().requestAuthorization(
options: [.alert, .badge, .sound]
) { granted, error in
if granted {
DispatchQueue.main.async {
application.registerForRemoteNotifications()
}
}
}
return true
}
}
Handling the Tap (Background and Killed State)
This delegate method fires whenever the user taps the notification itself or one of its action buttons. It covers both the background state (app is running but not in front) and the killed state (app was not running at all).
extension AppDelegate: UNUserNotificationCenterDelegate {
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
let userInfo = response.notification.request.content.userInfo
switch response.actionIdentifier {
case UNNotificationDefaultActionIdentifier:
// User tapped the notification body
if let urlString = userInfo["deep_link"] as? String,
let url = URL(string: urlString) {
handleDeepLink(url)
}
case "REPLY_ACTION":
if let urlString = userInfo["reply_link"] as? String,
let url = URL(string: urlString) {
handleDeepLink(url)
}
case "VIEW_PROFILE":
if let urlString = userInfo["profile_link"] as? String,
let url = URL(string: urlString) {
handleDeepLink(url)
}
default:
break
}
completionHandler()
}
}
One important detail: when the app launches from a killed state because the user tapped a notification, application(_:didFinishLaunchingWithOptions:) runs before this delegate method. Your UI may not be ready to navigate immediately. Keep this in mind when you write handleDeepLink.
Handling Foreground Delivery
When a notification arrives while the app is active in the foreground, iOS will not show a banner by default unless you opt in. You decide how to present it and whether to route immediately:
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
// Show banner and badge even when app is foregrounded
completionHandler([.banner, .badge, .sound])
// Optionally route immediately without waiting for a tap
// Uncomment if your UX calls for it
// let userInfo = notification.request.content.userInfo
// if let urlString = userInfo["deep_link"] as? String,
// let url = URL(string: urlString) {
// handleDeepLink(url)
// }
}
Most apps show the banner in the foreground and let the user tap it, rather than interrupting whatever they are doing. The code to silently route in the foreground is there if your use case calls for it.
Extracting the URL and Routing
The handleDeepLink function is where you translate a URL into a navigation action. Keep this function separate from the delegate so it can be called from multiple places (notifications, Universal Link continuations, in-app links).
func handleDeepLink(_ url: URL) {
guard url.host == "shop.example.com" || url.host == "app.example.com" else {
return
}
let path = url.path
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
let queryItems = components?.queryItems ?? []
DispatchQueue.main.async {
self.navigate(path: path, queryItems: queryItems)
}
}
func navigate(path: String, queryItems: [URLQueryItem]) {
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootVC = windowScene.windows.first?.rootViewController else {
return
}
// Example routing table
if path.hasPrefix("/orders/") {
let orderId = String(path.dropFirst("/orders/".count))
let vc = OrderDetailViewController(orderId: orderId)
push(viewController: vc, from: rootVC)
} else if path.hasPrefix("/conversations/") {
let conversationId = String(path.dropFirst("/conversations/".count))
let vc = ConversationViewController(conversationId: conversationId)
push(viewController: vc, from: rootVC)
} else if path.hasPrefix("/sale/") {
let category = String(path.dropFirst("/sale/".count))
let vc = SaleCategoryViewController(category: category)
push(viewController: vc, from: rootVC)
} else {
// Fallback: open home
popToRoot(from: rootVC)
}
}
func push(viewController: UIViewController, from root: UIViewController) {
if let nav = root as? UINavigationController {
nav.pushViewController(viewController, animated: true)
} else if let nav = root.children.first as? UINavigationController {
nav.pushViewController(viewController, animated: true)
}
}
The Killed State Timing Problem
When the app launches from scratch because of a notification tap, your root view controller may not have finished setting up by the time handleDeepLink is called. A practical solution is to store a pending URL and route after the UI is ready:
class AppDelegate: UIResponder, UIApplicationDelegate {
var pendingDeepLink: URL?
func handleDeepLink(_ url: URL) {
guard isAppReady else {
pendingDeepLink = url
return
}
// proceed with navigation
routeToURL(url)
}
}
Then in your root view controller's viewDidAppear:
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if let appDelegate = UIApplication.shared.delegate as? AppDelegate,
let pending = appDelegate.pendingDeepLink {
appDelegate.pendingDeepLink = nil
appDelegate.handleDeepLink(pending)
}
}
This pattern avoids race conditions without adding arbitrary delays.
Testing Without a Real Device
You can simulate push notifications using the simctl command on the iOS Simulator. Create a JSON file representing your APNs payload:
{
"aps": {
"alert": {
"title": "Test notification",
"body": "Tap to open order detail"
},
"sound": "default"
},
"deep_link": "https://shop.example.com/orders/9999"
}
Then send it to your running simulator:
xcrun simctl push booted com.yourcompany.yourapp payload.json
Replace com.yourcompany.yourapp with your actual bundle identifier. The notification appears immediately in the simulator. You can tap it to test all three app states by running this command while the app is foregrounded, backgrounded, and not running.
For device testing, tools like NWPusher or the Push Notifications Tester in App Store Connect let you send real APNs payloads to a physical device using your APNs certificate or key.
Tying It Together with a Deep Link Platform
Once you have this working end-to-end, you will likely want visibility into which notifications are actually driving users to the right screens. Instrumenting handleDeepLink with analytics events is a start, but if you are using Tolinku for deep linking, your notification URLs can pass through the Tolinku routing layer. This means attribution, click counts, and funnel analysis for push-driven sessions without extra instrumentation on the client.
The URL structure stays the same from the app's perspective. The routing logic you wrote above does not change. You get the analytics layer on top of the infrastructure you already built.
Common Issues
Notification tap does nothing on first launch. Almost always the killed-state timing problem. Store a pending URL as shown above.
willPresent fires but no banner appears. Check that you are passing .banner in your completion handler options. On iOS 14 and later, the banner option replaced the older .alert option.
Custom payload keys are nil in Swift. Cast from userInfo carefully. Values in the userInfo dictionary are Any, so userInfo["deep_link"] as? String can fail silently if the server sent a non-string type. Log the full userInfo dictionary during development to confirm the shape.
Universal Link opens Safari instead of the app. This is a domain association issue, not a notification issue. Check your apple-app-site-association file and confirm your app's Associated Domains entitlement is configured correctly. Apple's Supporting Universal Links in Your App documentation covers the full setup.
Notification category actions not appearing. Categories must be registered before the first notification arrives. Register them in application(_:didFinishLaunchingWithOptions:) before returning true.
Summary
The pattern for Universal Links in push notifications is consistent across all three app states:
- Put the destination URL in a custom key in your APNs payload.
- Implement
UNUserNotificationCenterDelegateand read the URL indidReceive. - For foreground delivery, decide in
willPresentwhether to show the banner, route immediately, or both. - Route through a single
handleDeepLinkfunction that translates URLs to navigation actions. - Handle the killed-state timing issue by storing a pending URL until the UI is ready.
The actual deep link URL is the same URL your app handles from any other entry point: tapped from the web, scanned from a QR code, or opened from another app. Notifications are just another delivery mechanism for the same routing logic. If you are using push notifications to bring lapsed users back, see our guide on re-engagement campaigns with deep links for strategies that pair well with the technical setup described here.
Get deep linking tips in your inbox
One email per week. No spam.