iOS Notification Actions Explained for Interview
Custom notification actions let users interact with a notification without opening the app. When the user long-presses a notification, buttons like Reply, Like, or Track Order appear inline — powered by UNNotificationAction and UNNotificationCategory.
The Mental Model
Before writing any code, it helps to understand the three-layer architecture:
1
2
3
| UNNotificationAction → a single button
UNNotificationCategory → a named group of those buttons
APNs payload "category" → tells iOS which group to render
|
The category string in your APNs payload is the linking pin between what your backend sends and what iOS renders. Get that string wrong and no buttons appear — with no error thrown.
Step 1 — Define Actions and Register a Category
Do this every launch inside application(_:didFinishLaunchingWithOptions:). iOS does not persist category registrations across launches.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| // A text input action — renders an inline reply field
let replyAction = UNTextInputNotificationAction(
identifier: "REPLY",
title: "Reply",
options: [], // [] = runs in background, app not opened
textInputButtonTitle: "Send",
textInputPlaceholder: "Type a message..."
)
// A standard action that foregrounds the app
let openAction = UNNotificationAction(
identifier: "OPEN_CHAT",
title: "Open Chat",
options: [.foreground]
)
// Group them under a category identifier
let messageCategory = UNNotificationCategory(
identifier: "NEW_MESSAGE", // must match payload exactly
actions: [replyAction, openAction],
intentIdentifiers: [],
options: []
)
// Register — must happen before any notifications arrive
UNUserNotificationCenter.current().setNotificationCategories([messageCategory])
|
Step 2 — Backend Sends the Matching category
Your backend constructs the APNs payload. The category value must be character-for-character identical to the UNNotificationCategory identifier — including case.
1
2
3
4
5
6
7
8
9
10
11
12
| {
"aps": {
"alert": {
"title": "New Message from Vasant",
"body": "Hey, are you free tonight?"
},
"sound": "default",
"category": "NEW_MESSAGE"
},
"sender_id": "USR-001",
"message_id": "MSG-4521"
}
|
Custom data like sender_id and message_id lives outside the aps dictionary — the aps key is reserved for Apple-defined keys only.
Step 3 — Handle the Tapped Action
All action taps, notification body taps, and dismissals land in the same delegate method. Use actionIdentifier to distinguish them.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
| func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void) {
let userInfo = response.notification.request.content.userInfo
let senderId = userInfo["sender_id"] as? String ?? ""
switch response.actionIdentifier {
case "REPLY":
// Must cast to UNTextInputNotificationResponse to access typed text
if let textResponse = response as? UNTextInputNotificationResponse {
MessageService.shared.send(reply: textResponse.userText, to: senderId)
}
case "OPEN_CHAT":
// App is already foregrounded due to .foreground option
NavigationRouter.shared.openChat(with: senderId)
case UNNotificationDefaultActionIdentifier:
// User tapped the notification body itself (not an action button)
NavigationRouter.shared.openChat(with: senderId)
case UNNotificationDismissActionIdentifier:
// Fires only if category was created with .customDismissAction option
Analytics.track("notification_dismissed")
default:
break
}
completionHandler() // always call this — skipping it can kill background task early
}
|
How It All Connects
1
2
3
4
5
6
7
8
9
10
11
12
| Payload "category": "NEW_MESSAGE"
│
▼
UNNotificationCategory(identifier: "NEW_MESSAGE")
│
┌─────┴──────┐
│ │
"REPLY" "OPEN_CHAT"
│ │
└─────┬──────┘
│
response.actionIdentifier switch
|
Action Options Cheatsheet
| Option | Behavior |
|---|
[] (empty) | Handled in background, app stays closed |
.foreground | Opens and foregrounds the app |
.destructive | Renders button title in red |
.authenticationRequired | Requires Face ID / Touch ID before firing |
Key Points to Nail in an Interview
Category registration must happen every launch. iOS does not persist UNNotificationCategory registrations across app launches. If setNotificationCategories isn’t called before the notification arrives, no buttons appear.
The category string is case-sensitive. "NEW_MESSAGE" and "new_message" are treated as entirely different identifiers. A mismatch silently falls back to default notification UI — no error, no warning.
Text input actions require a cast. To access what the user typed, cast UNNotificationResponse to UNTextInputNotificationResponse and read .userText. Forgetting the cast is a common interview trap.
UNNotificationDefaultActionIdentifier is the notification body tap. This system-provided identifier fires when the user taps the notification itself rather than an action button — handle it explicitly if you need deep linking.
Always call completionHandler(). Omitting it in didReceive response can cause iOS to terminate your background execution window early.
Custom data belongs outside aps. The aps dictionary is reserved for Apple-defined keys. Putting your own keys inside it produces undefined behavior — APNs may strip them silently.
Quick Reference — The Full Flow
1
2
3
4
5
6
| 1. Register UNNotificationCategory with actions → didFinishLaunchingWithOptions
2. Backend includes "category": "NEW_MESSAGE" → APNs payload
3. iOS matches category, renders action buttons → user long-presses notification
4. User taps an action → didReceive(response:) fires
5. Switch on actionIdentifier → handle each case
6. Call completionHandler() → always, in every branch
|