iOS Rich notifications

Why your iOS notification action buttons never appear

iOS does not read action buttons out of a push payload. It draws a category that your app registered — which is why the same payload that works on Android shows nothing on an iPhone.

· 4 min read · Notibase

You send a notification with two buttons. Android shows two buttons. iOS shows a notification with no buttons and no error, and the delivery log says delivered, because it was.

This is not a bug in your payload. It is the single biggest structural difference between the two platforms' notification models, and every push integration runs into it eventually.

Android reads the payload. iOS reads its own registry.

On Android, actions are part of the notification you construct on the device from whatever arrived. You can put labels and ids in the data payload and build Notification.Action objects from them at delivery time. The payload is the source of truth.

On iOS, the payload contains a category identifier and nothing else:

{
  "aps": {
    "alert": { "title": "Order shipped", "body": "Arriving Thursday" },
    "category": "ORDER_UPDATE",
    "mutable-content": 1
  }
}

ORDER_UPDATE is a lookup key. The system looks in the set of UNNotificationCategory objects your app registered with UNUserNotificationCenter, finds one with that identifier, and draws its actions. If nothing is registered under that identifier, it draws no buttons — silently, because from the system's point of view nothing went wrong.

So the classic version of the bug is: your backend can invent new button combinations freely, and your app can only render the ones it was compiled to know about.

The fix: register the category at delivery time

A Notification Service Extension runs on the device before the notification is displayed, when mutable-content: 1 is set. It gets about 30 seconds and it can modify the content — which means it can read button definitions out of your custom payload and register a matching category right then:

override func didReceive(_ request: UNNotificationRequest,
                         withContentHandler handler: @escaping (UNNotificationContent) -> Void) {
    let content = request.content.mutableCopy() as! UNMutableNotificationContent
    guard let buttons = content.userInfo["nb_buttons"] as? [[String: String]] else {
        return handler(content)
    }

    let actions = buttons.map {
        UNNotificationAction(identifier: $0["id"] ?? "", title: $0["text"] ?? "",
                             options: [.foreground])
    }
    // A stable identifier derived from the buttons, so identical button sets
    // reuse one category instead of accumulating thousands.
    let id = "nb_" + stableHash(of: buttons)
    let category = UNNotificationCategory(identifier: id, actions: actions,
                                          intentIdentifiers: [], options: [])
    content.categoryIdentifier = id
    // …register, then hand the content back
}

Three details in there are load-bearing, and each one is a bug we shipped before we shipped the fix.

setNotificationCategories replaces the entire set. It is not additive. If you call it with one category, every category your app registered at launch is gone, including the ones your main app depends on. You have to read the current set with getNotificationCategories, insert yours, and write the union back.

Do not use Swift's hashValue for the identifier. String.hashValue is seeded per process on Apple platforms, so the same buttons produce a different identifier on every launch and your registry grows without bound while the category you just registered stops matching the one in the payload. Use a stable hash you control — FNV-1a is five lines.

The extension is not single-threaded in the way you assume. Several notifications can arrive close together and each didReceive mutates shared registration state. Serialise the read-modify-write on a private queue or you will lose categories under load, intermittently, in a way that is close to impossible to reproduce on a desk.

While you are in there: attachments

The same extension is where image attachments happen. iOS will not fetch a remote image for you. You download it to a temp file and attach it:

let (tmp, _) = try await URLSession.shared.download(from: url)
let dest = tmp.deletingLastPathComponent()
    .appendingPathComponent(UUID().uuidString + ".jpg")   // extension matters
try FileManager.default.moveItem(at: tmp, to: dest)
content.attachments = [try UNNotificationAttachment(identifier: "img", url: dest)]

The file extension on dest is not cosmetic. UNNotificationAttachment validates the type from the path extension and refuses files it cannot classify, so a downloaded temp file with no extension silently produces no image.

And when the download fails or the 30 seconds run out, call the content handler with what you have. A notification without its picture is a notification. A notification whose extension timed out gets replaced by the system's fallback content, which is usually worse than the thing you were trying to improve.

The short version

  • iOS buttons come from a registered category, never from the payload.
  • Register it in a Notification Service Extension so your backend can define buttons without an app release.
  • Merge the category set, do not replace it.
  • Use a stable hash for the identifier.
  • Serialise mutation, and always call the handler.

Notibase's iOS SDK ships this extension so you do not have to write it — you add one target in Xcode and buttons and images defined server-side start rendering. The iOS guide has the steps, and Message content documents which fields map to what on each platform.