← All articles

April 5, 2026  ·  4 min read

iOS Notification Actions Explained for Interview

A concise but complete breakdown of how to add custom actions to iOS push notifications — covering UNNotificationAction, category registration, payload wiring, and the common pitfalls interviewers love to ask about.

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

OptionBehavior
[] (empty)Handled in background, app stays closed
.foregroundOpens and foregrounds the app
.destructiveRenders button title in red
.authenticationRequiredRequires 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