GitHub project

Notification inbox

How it works

Unlike other messages, inbox messages don’t necessarily appear immediately to your audience, and they don’t disappear when a person dismisses them. Instead, messages collect in a notification inbox that people can check at their leisure. You send inbox messages as part of an automation, broadcast, or transactional message.

The SDK gives you two ways to show the inbox in your app:

  • Recommended: add the visual inbox. Mount the SDK’s ready-made inbox views—a bell icon with an unread badge and a panel that lists messages. The SDK renders the inbox you style and publish in Customer.io using native UI—no webviews—and handles fetching messages, unread counts, opens, and clicks for you.
  • Advanced: build your own inbox. The SDK delivers inbox messages as JSON payloads, and you render them in your own inbox UI. Take this path when you need complete control over the inbox experience.

Add the visual inbox

The visual inbox renders the same inbox you publish for your website: your icon, colors, position, unread indicator, and message templates—including dark mode styles. When there’s nothing to show—the inbox isn’t published or has no messages—the inbox views render nothing, so they’re safe to mount unconditionally.

Prerequisites

  • Create, style, and publish a notification inbox in your workspace.
  • Set up in-app messaging in your app. The inbox module builds on the in-app messaging module.
  • For the visual inbox, NotificationInboxOverlay requires iOS 16+, and NotificationInboxBell and NotificationInboxView requires iOS 13+. On earlier versions, the inbox views aren’t available.

Install the inbox module

The visual inbox ships as its own module, so apps that don’t use it don’t carry its dependencies.

If you haven’t already, follow Apple’s instructions to add https://github.com/customerio/customerio-ios.git as a dependency in Xcode. Then select the MessagingInbox package in addition to the packages you already use.

Add the inbox pod to your Podfile and run pod install.

pod 'CustomerIOMessagingInbox'

Add the inbox to your app

NotificationInboxOverlay is the all-in-one option: a floating bell button with an unread badge that slides out the message list, with a scrim that closes it when a person taps outside. Mount it once, near the root of your view hierarchy, so it sits on top of your content.

import CioMessagingInbox
import SwiftUI

struct RootView: View {
    var body: some View {
        ZStack {
            AppContent()
            NotificationInboxOverlay()
        }
    }
}

That’s the whole integration. The overlay fetches messages, shows and updates the unread badge, marks messages opened when the panel opens, and reports opens and clicks to your inbox metrics.

Place the bell and message list yourself

If the floating overlay doesn’t fit your design, compose the two underlying views directly:

  • NotificationInboxBell: the bell button with the unread badge. Place it anywhere—a navigation bar, a toolbar, a tab—and use its tap callback to present your inbox.
  • NotificationInboxView: the message list without any surrounding chrome. Embed it in a sheet, a tab, or a dedicated inbox screen.
import CioMessagingInbox
import SwiftUI

struct DashboardView: View {
    @State private var isInboxPresented = false

    var body: some View {
        AppContent()
            .toolbar {
                NotificationInboxBell {
                    isInboxPresented = true
                }
            }
            .sheet(isPresented: $isInboxPresented) {
                NotificationInboxView()
            }
    }
}

In a UIKit app, host the views in a UIHostingController:

import CioMessagingInbox
import SwiftUI
import UIKit

class DashboardViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        // Place the bell where it fits your layout—for example, in your navigation bar
        let bell = UIHostingController(rootView: NotificationInboxBell { [weak self] in
            self?.showInbox()
        })
        bell.view.backgroundColor = .clear
        navigationItem.rightBarButtonItem = UIBarButtonItem(customView: bell.view)
    }

    // Present the message list however you like—a sheet, a pushed screen, or a tab
    func showInbox() {
        let inbox = UIHostingController(rootView: NotificationInboxView())
        present(inbox, animated: true)
    }
}

Handle message actions

When a person taps a message or a button inside it, the SDK runs the action you set on the message: it opens URLs, removes the message for dismiss actions, and so on. If your messages use deep links or you want to route navigation through your own code, set an InboxEventListener on the in-app messaging module.

import CioMessagingInApp

class InboxListener: InboxEventListener {
    func messageActionTaken(message: InboxMessage, actionName: String, actionValue: String) -> Bool {
        if actionValue.hasPrefix("myapp://") {
            // Navigate within your app
            DeepLinkRouter.shared.open(actionValue)
            // Return true to tell the SDK you handled the action,
            // so it doesn't run its default behavior
            return true
        }
        // Return false to let the SDK handle the action
        return false
    }
}

MessagingInApp.shared.setInboxEventListener(InboxListener())

The listener also receives optional, observe-only callbacks you can use for analytics or app state—their return values don’t change SDK behavior:

CallbackWhen it fires
messageActionTaken(message:actionName:actionValue:)A person taps a non-dismiss action. Return true to handle the action yourself, or false to let the SDK handle it.
messageShown(message:)A message renders in the visible inbox for the first time.
messageOpened(message:)The SDK marks a message opened—for example, when the panel opens.
messageDismissed(message:)A person dismisses a message, removing it from the inbox.

Dismiss actions always remove the message; they aren’t routed to your listener.

Visual inbox behaviors

  • The bell hides itself when there’s nothing to show. If your inbox isn’t published, or the person has no messages, the bell and panel don’t render at all.
  • Opening the inbox marks messages opened. Messages visible in the open panel—or in an embedded NotificationInboxView—are marked opened automatically, and the unread badge updates to match.
  • Dismissing a message deletes it. When a person dismisses a message, the SDK removes it from their inbox on every device.
  • Dark mode is automatic. The inbox follows the device’s appearance and re-renders when it changes, using the dark mode colors you set in Design Studio.
  • Style changes flow to your app. When you update your inbox’s styles or templates in Customer.io, the SDK picks up the changes on its next refresh—no app update required.

Build your own inbox

If you need complete control over the inbox experience, skip the visual inbox and work with the message payloads directly. Customer.io delivers inbox messages as JSON payloads, not fully rendered messages. The SDK helps you listen for these payloads, but you determine how to display them in your own inbox client.

Get the inbox instance

You’ll access inbox functionality through the inbox property on the in-app messaging module.

let inbox = MessagingInApp.shared.inbox

Inbox methods

The inbox instance provides several methods to manage messages.

MethodDescription
getMessages()Get all messages from the inbox. Returns an async array of messages.
getMessages(topic:)Get messages filtered by topic. Returns an async array of messages.
messages()AsyncStream for all messages with real-time updates. Automatically cleans up when task is cancelled.
messages(topic:)AsyncStream for messages filtered by topic. Automatically cleans up when task is cancelled.
addChangeListener(_:)Add a listener to be notified when messages change. Requires manual cleanup.
addChangeListener(_:topic:)Add a listener for messages filtered by topic. Requires manual cleanup.
removeChangeListener(_:)Remove a previously added change listener.
markMessageOpened(message:)Mark a message as opened.
markMessageUnopened(message:)Mark a message as unopened.
markMessageDeleted(message:)Mark a message as deleted.
trackMessageClicked(message:)Track a click on the message without an action name.
trackMessageClicked(message:actionName:)Track a click on the message with an action name.

Inbox message payloads

Inbox messages are delivered as a JSON payload. The SDK helps you listen for the payload, but you’ll render the content in your own inbox client.

The client payload includes the following fields, but you’re most concerned with the properties object, which represents your message content. By default, we’ll send a title and body field, but you can add other fields like an image or a link—whatever you set up your inbox to expect.

Make sure that your team members know what payloads to send—especially if you expect different payloads for different topics or types of messages.

FieldTypeDescription
messageIdstringUnique identifier for the message.
sentAtstringWhen the message was sent.
expiresAtstringWhen the message will expire.
openedbooleanWhether the message has been opened.
topicsarrayThe topics that the message belongs to.
typestringThe type of message.
propertiesobjectThe properties of the message.
{
    "messageId": "1234567890",
    "sentAt": "2026-02-05T12:00:00Z",
    "expiresAt": "2026-02-05T12:00:00Z",
    "opened": false,
    "topics": ["orders", "shipping"],
    "type": "order_shipped",
    "properties": {
        "title": "Hey Cool Person, your order shipped!",
        "body": "You can track your order #1234567890 here:",
        "link": "https://example.com/orders/1234567890"
    }
}

Inbox topics and types

When you send an inbox message, you can assign it to one or more topics. You can use these topics to filter messages when you fetch them. You can also use the topics to determine how to render the messages in your notification inbox.

Messages also have a type. Think of this like a sub-category or topic for a message. For example, you might have orders and sale topics, where orders don’t have images but sale topics might. Or, within the orders topic, you might have order_placed and order_shipped types, where order_placed lists order details and images of purchased products and order_shipped provides a link to the tracking information for the order that opens in a new tab.

Setup your notification inbox

Inbox messages are just JSON payloads. You’ll need to build your own inbox client to display the messages. The code below gives you a starting point, but you can build your own inbox client however you want.

Get messages

// Get all messages
let messages = await inbox.getMessages()

// Get messages filtered by topic
let promoMessages = await inbox.getMessages(topic: "promo")

Listen for message updates

The SDK provides two approaches for listening to message updates: AsyncStream (modern) and Listener pattern (classic).

AsyncStream (modern approach)

AsyncStream automatically cleans up when the task is cancelled, making it ideal for SwiftUI views or structured concurrency.

// Stream all messages
Task {
    for await messages in inbox.messages() {
        // Update your UI with the messages
        updateInboxUI(messages)
    }
}

// Stream messages filtered by topic
Task {
    for await messages in inbox.messages(topic: "promo") {
        // Update your UI with filtered messages
        updatePromotionsUI(messages)
    }
}

Listener pattern (classic approach)

The listener pattern requires manual cleanup but works well with UIKit view controllers.

// Your class must conform to NotificationInboxChangeListener
class InboxViewController: UIViewController, NotificationInboxChangeListener {

    override func viewDidLoad() {
        super.viewDidLoad()
        // Add listener (must be called on MainActor)
        Task { @MainActor in
            inbox.addChangeListener(self)
        }
    }

    // Implement the protocol method
    func onMessagesChanged(messages: [InboxMessage]) {
        // Update your UI with the messages
        updateInboxUI(messages)
    }

    deinit {
        // Remove listener when view controller is deallocated
        inbox.removeChangeListener(self)
    }
}

// Add listener for specific topic
Task { @MainActor in
    inbox.addChangeListener(self, topic: "promo")
}

Mark messages as opened or unopened

// Mark a message as opened
inbox.markMessageOpened(message: message)

// Mark a message as unopened
inbox.markMessageUnopened(message: message)

Track message clicks

// Track a click without an action name
inbox.trackMessageClicked(message: message)

// Track a click with an action name
inbox.trackMessageClicked(message: message, actionName: "view_details")

Delete messages

// Mark a message as deleted
inbox.markMessageDeleted(message: message)

Working with message properties

You can access message properties to display custom content in your inbox:

// Access message properties
let title = message.properties["title"] as? String
let body = message.properties["body"] as? String
let link = message.properties["link"] as? String
let imageUrl = message.properties["image"] as? String

// Handle message action when user taps
func handleMessageTap(_ message: InboxMessage) {
    // Mark as opened
    inbox.markMessageOpened(message: message)

    // Track click
    inbox.trackMessageClicked(message: message)

    // Open link if available
    if let link = message.properties["link"] as? String,
       let url = URL(string: link) {
        UIApplication.shared.open(url)
    }
}