Push notifications are one of the highest-value surfaces for deep linking. A notification about an order update, a new message, or a price drop is only useful if tapping it takes the user directly to the relevant screen. Yet notification deep links are implemented incorrectly more often than not: either the back stack is missing (pressing Back exits the app), the app state is ignored (the notification overwrites whatever the user was doing), or the deep link only works when the app is closed.
This tutorial covers notification deep links properly, from PendingIntent construction through back stack building, foreground and background handling, and testing.
The Two Entry Points
When a user taps a notification, Android needs to know what to do. That instruction comes from the PendingIntent you attach to the notification when building it. There are two approaches:
Activity PendingIntent: Directly opens an Activity with an Intent. Simple, but you manage the back stack yourself.
TaskStackBuilder PendingIntent: Builds a synthetic back stack from a list of Activities or Navigation destinations. This is the correct approach for deep links because it gives users a logical Back navigation experience.
The correct choice for most deep link notifications is TaskStackBuilder. Direct Activity intents leave users with no back stack, so pressing Back exits the app immediately.
Setting Up Notification Channels
Android 8.0 (API 26) requires notification channels. Before building notifications, register channels during app initialization:
object NotificationChannels {
const val ORDERS = "orders"
const val MESSAGES = "messages"
const val PROMOTIONS = "promotions"
fun createChannels(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val notificationManager =
context.getSystemService(NotificationManager::class.java)
val channels = listOf(
NotificationChannel(
ORDERS,
"Order Updates",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Updates about your orders"
},
NotificationChannel(
MESSAGES,
"Messages",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "New messages from other users"
},
NotificationChannel(
PROMOTIONS,
"Promotions",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Deals and promotional offers"
}
)
channels.forEach { notificationManager.createNotificationChannel(it) }
}
}
Call NotificationChannels.createChannels(context) in your Application.onCreate(). Creating channels is idempotent, so calling it on every launch is safe.
Building a Deep Link Notification with TaskStackBuilder
Here is a complete example for an order update notification that deep links to an order detail screen:
fun buildOrderNotification(
context: Context,
orderId: String,
statusText: String
): Notification {
// The destination intent: what screen to show when notification is tapped
val destinationIntent = Intent(
Intent.ACTION_VIEW,
"https://yourdomain.com/orders/$orderId".toUri(),
context,
MainActivity::class.java
)
// TaskStackBuilder constructs the back stack
val stackBuilder = TaskStackBuilder.create(context).apply {
// Add the parent chain first
addParentStack(MainActivity::class.java)
// Add the destination on top
addNextIntent(destinationIntent)
}
val pendingIntent = stackBuilder.getPendingIntent(
orderId.hashCode(), // Unique request code per notification
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(context, NotificationChannels.ORDERS)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("Order Update")
.setContentText(statusText)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.build()
}
PendingIntent.FLAG_IMMUTABLE is required for API 31 and above. FLAG_UPDATE_CURRENT ensures that if a notification with the same request code already exists, the intent data is updated rather than a duplicate being created.
The request code (orderId.hashCode()) should be unique per notification. If you use the same request code for two different notifications, the second will overwrite the first's PendingIntent.
Using NavDeepLinkBuilder with Navigation Component
If your app uses the Jetpack Navigation component, NavDeepLinkBuilder is cleaner than constructing intents manually. It understands your nav graph and builds the back stack from the graph structure automatically:
fun buildOrderNotification(
context: Context,
orderId: String,
statusText: String
): Notification {
val pendingIntent = NavDeepLinkBuilder(context)
.setGraph(R.navigation.nav_graph)
.setDestination(R.id.orderDetailFragment)
.setArguments(bundleOf("orderId" to orderId))
.createPendingIntent()
return NotificationCompat.Builder(context, NotificationChannels.ORDERS)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("Order Update")
.setContentText(statusText)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.build()
}
NavDeepLinkBuilder reads the nav graph to determine the parent destinations and constructs the back stack accordingly. The user arrives at orderDetailFragment with homeFragment (the graph's start destination) in the back stack. Pressing Back navigates to Home, not out of the app.
If the destination is inside a nested graph, NavDeepLinkBuilder includes the nested graph's start destination in the back stack as well.
Handling Foreground vs Background App States
The behavior when a notification is tapped depends on whether your app is running in the foreground, in the background, or not running at all.
App not running: Android creates a fresh task using your PendingIntent. The back stack from TaskStackBuilder or NavDeepLinkBuilder is created as-is. This works correctly with both approaches.
App in background: The system brings your existing task to the foreground. If you used TaskStackBuilder with FLAG_UPDATE_CURRENT, the destination intent's data is updated, and the Activity handles it. This depends on how your MainActivity is configured.
App in foreground: The user is actively using the app. Tapping the notification should not discard the current state. This is where things need attention.
For a singleTop or singleTask launch mode Activity, the system calls onNewIntent instead of creating a new instance. Handle this in your Activity:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// ...
handleIntent(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIntent(intent)
}
private fun handleIntent(intent: Intent) {
intent.data?.let { uri ->
navController.handleDeepLink(intent)
}
}
}
With NavDeepLinkBuilder, if the app is in the foreground, Navigation handles routing to the destination without destroying the current state. The destination is added to the current back stack rather than replacing it.
Notification Deep Links with Custom Data
Sometimes you want to pass extra data with the notification beyond what fits in the URL. For example, a notification might include a thumbnail URL for display that does not belong in the deep link URI.
Pass extra data through the Intent extras, then read them in your Activity or Fragment:
val destinationIntent = Intent(
Intent.ACTION_VIEW,
"https://yourdomain.com/orders/$orderId".toUri(),
context,
MainActivity::class.java
).apply {
putExtra("notification_id", notificationId)
putExtra("thumbnail_url", thumbnailUrl)
}
In the receiving Fragment:
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val orderId = args.orderId // From Navigation safe args
val thumbnailUrl = activity?.intent?.getStringExtra("thumbnail_url")
viewModel.loadOrder(orderId, thumbnailUrl)
}
Be careful with extras when using NavDeepLinkBuilder: extras passed to the builder are added to the PendingIntent extras, but Navigation uses the Bundle passed to setArguments() for navigation arguments. Keep navigation arguments in the Bundle and supplementary data in extras.
Handling Notification Deep Links in BroadcastReceiver
Some notification actions (like dismissing, replying, or button taps) route through a BroadcastReceiver rather than directly to an Activity. If you need to navigate after receiving a broadcast, you have to start the Activity from the receiver:
class NotificationActionReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val orderId = intent.getStringExtra("order_id") ?: return
val action = intent.getStringExtra("action") ?: return
when (action) {
"MARK_RECEIVED" -> {
// Handle the action
OrderRepository.markReceived(orderId)
// Navigate to confirmation
val launchIntent = NavDeepLinkBuilder(context)
.setGraph(R.navigation.nav_graph)
.setDestination(R.id.orderConfirmationFragment)
.setArguments(bundleOf("orderId" to orderId))
.createTaskStackBuilder()
.getPendingIntent(
orderId.hashCode(),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
launchIntent?.send()
}
}
}
}
Note that BroadcastReceiver.onReceive is called on the main thread with a limited execution window. Do not perform long-running operations here. If you need async work (like a network call) before navigating, use goAsync() to extend the receiver's lifetime or dispatch the work to a WorkManager task.
Notification Permission on Android 13 and Later
Android 13 (API 33) added a runtime permission for notifications: POST_NOTIFICATIONS. Apps targeting API 33 or higher must request this permission before posting notifications.
// Check and request notification permission
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
when {
ContextCompat.checkSelfPermission(
this,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED -> {
// Permission granted, can post notifications
}
shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) -> {
// Show rationale then request
showNotificationPermissionRationale()
}
else -> {
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
Without this permission on API 33+ devices, your notifications will be silently dropped. The deep link code is correct, but the notification never appears.
Declare the permission in your manifest:
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

Testing Notification Deep Links
Testing notification deep links requires verifying that:
- The notification appears with the correct content
- Tapping the notification navigates to the correct screen
- The back stack is correct after navigation
- The behavior is correct in all app states (cold start, background, foreground)
For automated testing, use NotificationListenerService or test the PendingIntent directly:
@Test
fun notificationDeepLinkNavigatesToOrderDetail() {
val orderId = "test-order-123"
val notification = buildOrderNotification(
ApplicationProvider.getApplicationContext(),
orderId,
"Your order has shipped"
)
// Extract and fire the content intent
val contentIntent = notification.contentIntent
val scenario = ActivityScenario.launch<MainActivity>(
Intent(Intent.ACTION_MAIN)
.addCategory(Intent.CATEGORY_LAUNCHER)
)
scenario.onActivity { activity ->
contentIntent.send()
}
// Verify navigation state
scenario.onActivity { activity ->
val navController = activity.findNavController(R.id.nav_host_fragment)
assertThat(navController.currentDestination?.id)
.isEqualTo(R.id.orderDetailFragment)
}
}
For manual testing during development, use ADB to simulate a notification tap:
adb shell am start \
-W -a android.intent.action.VIEW \
-d "https://yourdomain.com/orders/test-order-123" \
com.yourcompany.app
This bypasses the notification system and tests the deep link routing directly. It does not test the notification construction, but it verifies that the destination Intent routes correctly.
Common Mistakes
Using FLAG_MUTABLE when FLAG_IMMUTABLE is required. On Android 12 and above, all PendingIntent objects must be explicitly marked as mutable or immutable. Use FLAG_IMMUTABLE unless you have a specific reason to allow the Intent to be mutated.
Same request code for all notifications. If you build multiple notifications with the same request code, each new PendingIntent overwrites the previous one. Use unique request codes, typically derived from the notification's data (an order ID, user ID, etc.).
No back stack. Constructing a PendingIntent directly from an Activity Intent without TaskStackBuilder leaves the user with an empty back stack. Always use TaskStackBuilder or NavDeepLinkBuilder.
Not handling onNewIntent. If your Activity has singleTop or singleTask launch mode and you do not handle onNewIntent, notification taps when the app is already running go nowhere.
Missing notification permission on API 33+. Notifications are silently dropped without POST_NOTIFICATIONS permission on Android 13 and later.
For the full App Links setup that makes HTTPS deep link URLs work with your app, see the configuring Android guide, and for an overview of all deep linking concepts, see our complete guide to deep linking.
Summary
Notification deep links require correct PendingIntent construction, proper back stack building, and handling for all app lifecycle states.
Use NavDeepLinkBuilder if your app uses the Jetpack Navigation component; it handles back stack construction automatically from your nav graph. Use TaskStackBuilder for manual Activity stack construction in simpler apps.
The key rules: always build a back stack (never a bare Activity Intent), always handle onNewIntent for foreground and background cases, use unique request codes per notification, and request POST_NOTIFICATIONS permission for Android 13+ users.
Getting this right means a user tapping your notification always lands exactly where they should, with a logical navigation history, in any app state.
Get deep linking tips in your inbox
One email per week. No spam.