{"id":469,"date":"2026-03-17T13:00:00","date_gmt":"2026-03-17T18:00:00","guid":{"rendered":"https:\/\/tolinku.com\/blog\/?p=469"},"modified":"2026-03-07T04:36:00","modified_gmt":"2026-03-07T09:36:00","slug":"universal-links-in-push-notifications","status":"publish","type":"post","link":"https:\/\/tolinku.com\/blog\/universal-links-in-push-notifications\/","title":{"rendered":"Universal Links in Push Notifications: Setup Guide"},"content":{"rendered":"\n<p>Push notifications are most useful when they take the user exactly where they need to go. When combined with a solid <a href=\"https:\/\/tolinku.com\/blog\/push-notification-strategy\/\">push notification deep link strategy<\/a>, 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.<\/p>\n\n\n\n<p>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.<\/p>\n\n\n\n<p>If you need a primer on Universal Links themselves before continuing, our guide on <a href=\"https:\/\/tolinku.com\/blog\/universal-links-everything-you-need-to-know\/\">everything you need to know about Universal Links<\/a> covers the fundamentals, or the <a href=\"https:\/\/tolinku.com\/docs\/developer\/universal-links\/\">Tolinku Universal Links documentation<\/a> covers the apple-app-site-association file, entitlements, and domain setup.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The APNs Payload<\/h2>\n\n\n\n<p>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 <code>aps<\/code> dictionary.<\/p>\n\n\n\n<pre><code class=\"language-json\">{\n  &quot;aps&quot;: {\n    &quot;alert&quot;: {\n      &quot;title&quot;: &quot;Your order has shipped&quot;,\n      &quot;body&quot;: &quot;Order #4821 is on its way. Tap to track.&quot;\n    },\n    &quot;badge&quot;: 1,\n    &quot;sound&quot;: &quot;default&quot;\n  },\n  &quot;deep_link&quot;: &quot;https:\/\/shop.example.com\/orders\/4821&quot;\n}\n<\/code><\/pre>\n\n\n\n<p>The <code>deep_link<\/code> key is custom. You choose the name. Whatever you pick, use it consistently across your backend and your app. Some teams use <code>url<\/code>, others <code>link<\/code> or <code>destination<\/code>. Pick one and document it.<\/p>\n\n\n\n<p>The value is a full Universal Link URL. When your app receives this notification, it reads <code>deep_link<\/code> from the payload and routes accordingly.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Rich Notifications with Deep Links<\/h3>\n\n\n\n<p>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:<\/p>\n\n\n\n<pre><code class=\"language-json\">{\n  &quot;aps&quot;: {\n    &quot;alert&quot;: {\n      &quot;title&quot;: &quot;Flash sale starts now&quot;,\n      &quot;body&quot;: &quot;Up to 40% off running shoes. Limited time.&quot;\n    },\n    &quot;mutable-content&quot;: 1,\n    &quot;sound&quot;: &quot;default&quot;\n  },\n  &quot;deep_link&quot;: &quot;https:\/\/shop.example.com\/sale\/running-shoes&quot;,\n  &quot;image_url&quot;: &quot;https:\/\/cdn.example.com\/banners\/running-sale.jpg&quot;\n}\n<\/code><\/pre>\n\n\n\n<p>The <code>mutable-content: 1<\/code> 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 <code>deep_link<\/code> key at this stage if needed, though typically you just pass it through untouched.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Action Buttons with Different Destinations<\/h3>\n\n\n\n<p>Notification categories let you add action buttons. Each button can route to a different screen by carrying its own URL in the payload:<\/p>\n\n\n\n<pre><code class=\"language-json\">{\n  &quot;aps&quot;: {\n    &quot;alert&quot;: {\n      &quot;title&quot;: &quot;New message from Sarah&quot;,\n      &quot;body&quot;: &quot;Hey, are you coming to the event tonight?&quot;\n    },\n    &quot;category&quot;: &quot;MESSAGE&quot;,\n    &quot;sound&quot;: &quot;default&quot;\n  },\n  &quot;deep_link&quot;: &quot;https:\/\/app.example.com\/conversations\/sarah-82&quot;,\n  &quot;reply_link&quot;: &quot;https:\/\/app.example.com\/conversations\/sarah-82\/reply&quot;,\n  &quot;profile_link&quot;: &quot;https:\/\/app.example.com\/users\/sarah-82&quot;\n}\n<\/code><\/pre>\n\n\n\n<p>You register the <code>MESSAGE<\/code> category with its actions at app launch:<\/p>\n\n\n\n<pre><code class=\"language-swift\">let replyAction = UNNotificationAction(\n    identifier: &quot;REPLY_ACTION&quot;,\n    title: &quot;Reply&quot;,\n    options: [.foreground]\n)\n\nlet viewProfileAction = UNNotificationAction(\n    identifier: &quot;VIEW_PROFILE&quot;,\n    title: &quot;View Profile&quot;,\n    options: [.foreground]\n)\n\nlet messageCategory = UNNotificationCategory(\n    identifier: &quot;MESSAGE&quot;,\n    actions: [replyAction, viewProfileAction],\n    intentIdentifiers: [],\n    options: []\n)\n\nUNUserNotificationCenter.current().setNotificationCategories([messageCategory])\n<\/code><\/pre>\n\n\n\n<p>When the user taps an action, you read the matching key from the payload to get the correct destination URL.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Handling Notification Taps<\/h2>\n\n\n\n<p>Now for the code that responds to taps. You need to implement <code>UNUserNotificationCenterDelegate<\/code>. The two methods that matter are <code>userNotificationCenter(_:didReceive:withCompletionHandler:)<\/code> for background and killed state taps, and <code>userNotificationCenter(_:willPresent:withCompletionHandler:)<\/code> for foreground delivery.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">AppDelegate Setup<\/h3>\n\n\n\n<pre><code class=\"language-swift\">import UIKit\nimport UserNotifications\n\n@main\nclass AppDelegate: UIResponder, UIApplicationDelegate {\n\n    func application(\n        _ application: UIApplication,\n        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?\n    ) -&gt; Bool {\n\n        UNUserNotificationCenter.current().delegate = self\n\n        UNUserNotificationCenter.current().requestAuthorization(\n            options: [.alert, .badge, .sound]\n        ) { granted, error in\n            if granted {\n                DispatchQueue.main.async {\n                    application.registerForRemoteNotifications()\n                }\n            }\n        }\n\n        return true\n    }\n}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Handling the Tap (Background and Killed State)<\/h3>\n\n\n\n<p>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).<\/p>\n\n\n\n<pre><code class=\"language-swift\">extension AppDelegate: UNUserNotificationCenterDelegate {\n\n    func userNotificationCenter(\n        _ center: UNUserNotificationCenter,\n        didReceive response: UNNotificationResponse,\n        withCompletionHandler completionHandler: @escaping () -&gt; Void\n    ) {\n        let userInfo = response.notification.request.content.userInfo\n\n        switch response.actionIdentifier {\n        case UNNotificationDefaultActionIdentifier:\n            \/\/ User tapped the notification body\n            if let urlString = userInfo[&quot;deep_link&quot;] as? String,\n               let url = URL(string: urlString) {\n                handleDeepLink(url)\n            }\n\n        case &quot;REPLY_ACTION&quot;:\n            if let urlString = userInfo[&quot;reply_link&quot;] as? String,\n               let url = URL(string: urlString) {\n                handleDeepLink(url)\n            }\n\n        case &quot;VIEW_PROFILE&quot;:\n            if let urlString = userInfo[&quot;profile_link&quot;] as? String,\n               let url = URL(string: urlString) {\n                handleDeepLink(url)\n            }\n\n        default:\n            break\n        }\n\n        completionHandler()\n    }\n}\n<\/code><\/pre>\n\n\n\n<p>One important detail: when the app launches from a killed state because the user tapped a notification, <code>application(_:didFinishLaunchingWithOptions:)<\/code> runs before this delegate method. Your UI may not be ready to navigate immediately. Keep this in mind when you write <code>handleDeepLink<\/code>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Handling Foreground Delivery<\/h3>\n\n\n\n<p>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:<\/p>\n\n\n\n<pre><code class=\"language-swift\">func userNotificationCenter(\n    _ center: UNUserNotificationCenter,\n    willPresent notification: UNNotification,\n    withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -&gt; Void\n) {\n    \/\/ Show banner and badge even when app is foregrounded\n    completionHandler([.banner, .badge, .sound])\n\n    \/\/ Optionally route immediately without waiting for a tap\n    \/\/ Uncomment if your UX calls for it\n    \/\/ let userInfo = notification.request.content.userInfo\n    \/\/ if let urlString = userInfo[&quot;deep_link&quot;] as? String,\n    \/\/    let url = URL(string: urlString) {\n    \/\/     handleDeepLink(url)\n    \/\/ }\n}\n<\/code><\/pre>\n\n\n\n<p>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.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Extracting the URL and Routing<\/h2>\n\n\n\n<p>The <code>handleDeepLink<\/code> 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).<\/p>\n\n\n\n<pre><code class=\"language-swift\">func handleDeepLink(_ url: URL) {\n    guard url.host == &quot;shop.example.com&quot; || url.host == &quot;app.example.com&quot; else {\n        return\n    }\n\n    let path = url.path\n    let components = URLComponents(url: url, resolvingAgainstBaseURL: false)\n    let queryItems = components?.queryItems ?? []\n\n    DispatchQueue.main.async {\n        self.navigate(path: path, queryItems: queryItems)\n    }\n}\n\nfunc navigate(path: String, queryItems: [URLQueryItem]) {\n    guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,\n          let rootVC = windowScene.windows.first?.rootViewController else {\n        return\n    }\n\n    \/\/ Example routing table\n    if path.hasPrefix(&quot;\/orders\/&quot;) {\n        let orderId = String(path.dropFirst(&quot;\/orders\/&quot;.count))\n        let vc = OrderDetailViewController(orderId: orderId)\n        push(viewController: vc, from: rootVC)\n    } else if path.hasPrefix(&quot;\/conversations\/&quot;) {\n        let conversationId = String(path.dropFirst(&quot;\/conversations\/&quot;.count))\n        let vc = ConversationViewController(conversationId: conversationId)\n        push(viewController: vc, from: rootVC)\n    } else if path.hasPrefix(&quot;\/sale\/&quot;) {\n        let category = String(path.dropFirst(&quot;\/sale\/&quot;.count))\n        let vc = SaleCategoryViewController(category: category)\n        push(viewController: vc, from: rootVC)\n    } else {\n        \/\/ Fallback: open home\n        popToRoot(from: rootVC)\n    }\n}\n\nfunc push(viewController: UIViewController, from root: UIViewController) {\n    if let nav = root as? UINavigationController {\n        nav.pushViewController(viewController, animated: true)\n    } else if let nav = root.children.first as? UINavigationController {\n        nav.pushViewController(viewController, animated: true)\n    }\n}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">The Killed State Timing Problem<\/h3>\n\n\n\n<p>When the app launches from scratch because of a notification tap, your root view controller may not have finished setting up by the time <code>handleDeepLink<\/code> is called. A practical solution is to store a pending URL and route after the UI is ready:<\/p>\n\n\n\n<pre><code class=\"language-swift\">class AppDelegate: UIResponder, UIApplicationDelegate {\n    var pendingDeepLink: URL?\n\n    func handleDeepLink(_ url: URL) {\n        guard isAppReady else {\n            pendingDeepLink = url\n            return\n        }\n        \/\/ proceed with navigation\n        routeToURL(url)\n    }\n}\n<\/code><\/pre>\n\n\n\n<p>Then in your root view controller&#39;s <code>viewDidAppear<\/code>:<\/p>\n\n\n\n<pre><code class=\"language-swift\">override func viewDidAppear(_ animated: Bool) {\n    super.viewDidAppear(animated)\n\n    if let appDelegate = UIApplication.shared.delegate as? AppDelegate,\n       let pending = appDelegate.pendingDeepLink {\n        appDelegate.pendingDeepLink = nil\n        appDelegate.handleDeepLink(pending)\n    }\n}\n<\/code><\/pre>\n\n\n\n<p>This pattern avoids race conditions without adding arbitrary delays.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Testing Without a Real Device<\/h2>\n\n\n\n<p>You can simulate push notifications using the <code>simctl<\/code> command on the iOS Simulator. Create a JSON file representing your APNs payload:<\/p>\n\n\n\n<pre><code class=\"language-json\">{\n  &quot;aps&quot;: {\n    &quot;alert&quot;: {\n      &quot;title&quot;: &quot;Test notification&quot;,\n      &quot;body&quot;: &quot;Tap to open order detail&quot;\n    },\n    &quot;sound&quot;: &quot;default&quot;\n  },\n  &quot;deep_link&quot;: &quot;https:\/\/shop.example.com\/orders\/9999&quot;\n}\n<\/code><\/pre>\n\n\n\n<p>Then send it to your running simulator:<\/p>\n\n\n\n<pre><code class=\"language-bash\">xcrun simctl push booted com.yourcompany.yourapp payload.json\n<\/code><\/pre>\n\n\n\n<p>Replace <code>com.yourcompany.yourapp<\/code> 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.<\/p>\n\n\n\n<p>For device testing, tools like <a href=\"https:\/\/github.com\/norio-nomura\/NWPusher\" rel=\"nofollow noopener\" target=\"_blank\">NWPusher<\/a> or the <a href=\"https:\/\/developer.apple.com\/documentation\/usernotifications\/testing-notifications-using-the-push-notification-console\" rel=\"nofollow noopener\" target=\"_blank\">Push Notifications Tester<\/a> in App Store Connect let you send real APNs payloads to a physical device using your APNs certificate or key.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Tying It Together with a Deep Link Platform<\/h2>\n\n\n\n<p>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 <code>handleDeepLink<\/code> with analytics events is a start, but if you are using <a href=\"https:\/\/tolinku.com\/features\/deep-linking\">Tolinku for deep linking<\/a>, 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.<\/p>\n\n\n\n<p>The URL structure stays the same from the app&#39;s perspective. The routing logic you wrote above does not change. You get the analytics layer on top of the infrastructure you already built.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Common Issues<\/h2>\n\n\n\n<p><strong>Notification tap does nothing on first launch.<\/strong> Almost always the killed-state timing problem. Store a pending URL as shown above.<\/p>\n\n\n\n<p><strong><code>willPresent<\/code> fires but no banner appears.<\/strong> Check that you are passing <code>.banner<\/code> in your completion handler options. On iOS 14 and later, the banner option replaced the older <code>.alert<\/code> option.<\/p>\n\n\n\n<p><strong>Custom payload keys are <code>nil<\/code> in Swift.<\/strong> Cast from <code>userInfo<\/code> carefully. Values in the <code>userInfo<\/code> dictionary are <code>Any<\/code>, so <code>userInfo[&quot;deep_link&quot;] as? String<\/code> can fail silently if the server sent a non-string type. Log the full <code>userInfo<\/code> dictionary during development to confirm the shape.<\/p>\n\n\n\n<p><strong>Universal Link opens Safari instead of the app.<\/strong> This is a domain association issue, not a notification issue. Check your <code>apple-app-site-association<\/code> file and confirm your app&#39;s Associated Domains entitlement is configured correctly. Apple&#39;s <a href=\"https:\/\/developer.apple.com\/documentation\/xcode\/supporting-universal-links-in-your-app\" rel=\"nofollow noopener\" target=\"_blank\">Supporting Universal Links in Your App<\/a> documentation covers the full setup.<\/p>\n\n\n\n<p><strong>Notification category actions not appearing.<\/strong> Categories must be registered before the first notification arrives. Register them in <code>application(_:didFinishLaunchingWithOptions:)<\/code> before returning <code>true<\/code>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Summary<\/h2>\n\n\n\n<p>The pattern for Universal Links in push notifications is consistent across all three app states:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Put the destination URL in a custom key in your APNs payload.<\/li>\n<li>Implement <code>UNUserNotificationCenterDelegate<\/code> and read the URL in <code>didReceive<\/code>.<\/li>\n<li>For foreground delivery, decide in <code>willPresent<\/code> whether to show the banner, route immediately, or both.<\/li>\n<li>Route through a single <code>handleDeepLink<\/code> function that translates URLs to navigation actions.<\/li>\n<li>Handle the killed-state timing issue by storing a pending URL until the UI is ready.<\/li>\n<\/ol>\n\n\n\n<p>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 <a href=\"https:\/\/tolinku.com\/blog\/re-engagement-campaigns\/\">re-engagement campaigns with deep links<\/a> for strategies that pair well with the technical setup described here.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Open specific app screens from push notifications using Universal Links. Learn payload configuration and deep link handling on tap.<\/p>\n","protected":false},"author":2,"featured_media":468,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"Universal Links in Push Notifications: Setup Guide","rank_math_description":"Open specific app screens from push notifications using Universal Links. Learn payload configuration and deep link handling on tap.","rank_math_focus_keyword":"universal links push notifications","rank_math_canonical_url":"","rank_math_facebook_title":"","rank_math_facebook_description":"","rank_math_facebook_image":"https:\/\/tolinku.com\/blog\/wp-content\/uploads\/2026\/03\/og-universal-links-in-push-notifications.png","rank_math_facebook_image_id":"","rank_math_twitter_title":"","rank_math_twitter_description":"","rank_math_twitter_image":"https:\/\/tolinku.com\/blog\/wp-content\/uploads\/2026\/03\/og-universal-links-in-push-notifications.png","footnotes":""},"categories":[12],"tags":[85,20,24,69,84,31,22,86],"class_list":["post-469","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-ios","tag-apns","tag-deep-linking","tag-ios","tag-mobile-development","tag-push-notifications","tag-swift","tag-universal-links","tag-user-engagement"],"_links":{"self":[{"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts\/469","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/comments?post=469"}],"version-history":[{"count":2,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts\/469\/revisions"}],"predecessor-version":[{"id":2788,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts\/469\/revisions\/2788"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/media\/468"}],"wp:attachment":[{"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/media?parent=469"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/categories?post=469"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/tags?post=469"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}