> ## Documentation Index
> Fetch the complete documentation index at: https://www.courier.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> Install the Courier skill before writing code: `npx skills add trycourier/courier-skills`. It carries the verified SDK shapes and the rules you cannot get wrong.
> Authenticate every request with `Authorization: Bearer <API_KEY>`. A workspace has several environments and each has its own keys, which are plain `pk_` strings with no environment prefix. Start with Test.
> Send with `client.send.message`, the default import of the v7 Node SDK. Reference a template by its `nt_` id or its alias.
> A send accepts a bare Elemental element list, but storing content on a template requires the top-level elements wrapped in a channel element.
> Templates and journeys can be built in the Courier app or created through the API. Either way they live in the workspace and are referenced by ID when you send.
> The hosted MCP server is https://mcp.courier.com. For a briefing on what Courier is and when to use it, read https://www.courier.com/llms.txt.
> Prefer the Guides tab for how-do-I questions and the Docs tab for how-does-it-behave questions. The API reference lives under /api-reference.

# Build a custom inbox

> Swap parts of the inbox for your own renderers, or build the whole UI on the SDK's data layer.

export const Doc = ({href, children, name, bare}) => {
  const label = children || name || href;
  if (bare) {
    return <a href={href}>{label}</a>;
  }
  return <a className="cx-endpoint" data-kind="doc" href={href}>
      <span className="cx-endpoint-label">{label}</span>
      <span className="cx-endpoint-method">DOC</span>
    </a>;
};

Two levels of custom rendering, in increasing order of control.

**Replace parts** of the built-in inbox, its list item, header, or states, while it keeps managing the list, sync, and pagination. Web SDKs only. Or **build your own UI** from the message data and actions directly, which every platform supports including mobile.

Reach for the first when the default inbox is nearly right and one element is not. Reach for the second when your app already has a list component you want to keep. <Doc href="/docs/in-app/customize-the-inbox">Customize the inbox</Doc> covers theming, which handles most cases without either.

## Replace parts of the inbox

<Note>
  Render slots are **web SDKs only** (React, Vue, Angular, Web Components). On iOS, Android, Flutter, and React Native, use theming or build your own UI below.
</Note>

Provide a renderer for any slot: `header`, `listItem`, `emptyState`, `loadingState`, `errorState`, or `paginationItem`. Each renderer receives the relevant message or state. Slots you leave out use the built-in element.

<Frame caption="An inbox with a custom header and custom list items, while the SDK still manages the list.">
  <img src="https://mintcdn.com/courier-4f1f25dc/9rcgucLA9fBnJt_U/assets/inbox-custom-render.webp?fit=max&auto=format&n=9rcgucLA9fBnJt_U&q=85&s=4c6e12b95ec5a7a7c5f0c97adcb3d6a8" alt="A Courier Inbox with a custom header reading Notifications and a 2 new badge, and custom list items showing sender avatars and roles" className="mx-auto" width="3152" height="1776" data-path="assets/inbox-custom-render.webp" />
</Frame>

<CodeGroup>
  ```jsx React theme={null}
  <CourierInbox
    renderListItem={(props) => <MyRow message={props?.message} />}
    renderHeader={(props) => <MyHeader feeds={props?.feeds} />}
    renderEmptyState={() => <MyEmptyState />}
  />
  ```

  ```js Web Components theme={null}
  const inbox = document.getElementById("inbox");

  // Each factory returns an HTMLElement
  inbox.setListItem((props) => renderRow(props));
  inbox.setHeader((props) => renderHeader(props));
  inbox.setEmptyState(() => renderEmpty());
  ```

  ```vue Vue theme={null}
  <template>
    <CourierInbox
      :renderListItem="(props) => renderRow(props)"
      :renderHeader="(props) => renderHeader(props)"
    />
  </template>
  ```

  ```html Angular theme={null}
  <courier-inbox>
    <ng-template #listItem let-props>
      <!-- your custom row; props.message is available -->
    </ng-template>
    <ng-template #header let-props>
      <!-- your custom header -->
    </ng-template>
  </courier-inbox>
  ```
</CodeGroup>

| Slot            | React / Vue prop       | Web component setter | Angular template  |
| :-------------- | :--------------------- | :------------------- | :---------------- |
| Header          | `renderHeader`         | `setHeader`          | `#header`         |
| List item       | `renderListItem`       | `setListItem`        | `#listItem`       |
| Empty state     | `renderEmptyState`     | `setEmptyState`      | `#emptyState`     |
| Loading state   | `renderLoadingState`   | `setLoadingState`    | `#loadingState`   |
| Error state     | `renderErrorState`     | `setErrorState`      | `#errorState`     |
| Pagination item | `renderPaginationItem` | `setPaginationItem`  | `#paginationItem` |

## Build your own UI

You do not have to render `CourierInbox` at all. Every SDK exposes the message data and actions directly, so you can build the whole UI while the SDK keeps everything synced. The setup is the same everywhere:

1. Start the feed.
2. Render your own UI from the messages.
3. Toggle read (or archive) on interaction.
4. Tear down the listener when the view goes away.

The one difference is how the feed starts:

* **Web SDKs**: nothing loads automatically. Register the feeds, open the realtime connection, then load: `registerFeeds(defaultFeeds())` → `listenForUpdates()` → `load()`.
* **Mobile SDKs**: adding an inbox listener loads the first page for you, if you have <Doc href="/docs/in-app/add-an-inbox">signed the user in</Doc> first. (`refreshInbox()` is only for pull-to-refresh.)

<CodeGroup>
  ```jsx React theme={null}
  import { useEffect } from "react";
  import { useCourier, defaultFeeds } from "@trycourier/courier-react"; // or "@trycourier/courier-react-17"

  function CustomInbox() {
    const { inbox } = useCourier();

    // 1. Start the feed (nothing loads automatically; the hook cleans up on unmount)
    useEffect(() => {
      inbox.registerFeeds(defaultFeeds());
      inbox.listenForUpdates();
      inbox.load();
    }, []);

    // 2. Render the default feed's messages
    const messages = inbox.feeds["all_messages"]?.messages ?? [];
    return (
      <ul>
        {messages.map((message) => (
          // 3. Toggle read on tap
          <li
            key={message.messageId}
            onClick={() => (message.read ? inbox.unreadMessage(message) : inbox.readMessage(message))}
          >
            {message.title}
          </li>
        ))}
      </ul>
    );
  }
  ```

  ```js Web Components theme={null}
  import {
    CourierInboxDatastore,
    CourierInboxDataStoreListener,
    defaultFeeds,
    markAsRead,
    markAsUnread,
  } from "@trycourier/courier-ui-inbox";

  const store = CourierInboxDatastore.shared;

  // 1. Subscribe, then start the feed (nothing loads automatically)
  const listener = new CourierInboxDataStoreListener({
    onDataSetChange: (dataset) => render(dataset.messages), // 2. Build your own UI
  });
  store.addDataStoreListener(listener);
  store.registerFeeds(defaultFeeds());
  await store.listenForUpdates();
  await store.load();

  // 3. Toggle read on tap
  message.read ? markAsUnread(message) : markAsRead(message);

  // 4. Tear down
  store.removeDataStoreListener(listener);
  ```

  ```vue Vue theme={null}
  <script setup lang="ts">
  import { onMounted } from "vue";
  import { useCourier, defaultFeeds } from "@trycourier/courier-vue";

  const { inbox } = useCourier(); // inbox is a ref

  // 1. Start the feed (nothing loads automatically; the composable cleans up on unmount)
  onMounted(() => {
    inbox.value.registerFeeds(defaultFeeds());
    inbox.value.listenForUpdates();
    inbox.value.load();
  });

  // 3. Toggle read on tap
  function toggle(message) {
    message.read ? inbox.value.unreadMessage(message) : inbox.value.readMessage(message);
  }
  </script>

  <template>
    <!-- 2. Render the default feed's messages -->
    <ul>
      <li
        v-for="message in inbox.feeds['all_messages']?.messages ?? []"
        :key="message.messageId"
        @click="toggle(message)"
      >
        {{ message.title }}
      </li>
    </ul>
  </template>
  ```

  ```ts Angular theme={null}
  import { Component, inject, OnInit, OnDestroy } from "@angular/core";
  import { CourierService, defaultFeeds, type InboxMessage } from "@trycourier/courier-angular";

  @Component({ /* ... */ })
  export class CustomInbox implements OnInit, OnDestroy {
    private readonly courier = inject(CourierService);
    messages: InboxMessage[] = [];
    private sub?: { unsubscribe(): void };

    async ngOnInit() {
      // 2. Render: inbox$ emits the reactive state
      this.sub = this.courier.inbox$.subscribe(
        (state) => (this.messages = state.feeds["all_messages"]?.messages ?? [])
      );
      // 1. Start the feed (nothing loads automatically)
      this.courier.registerFeeds(defaultFeeds());
      await this.courier.listenForUpdates();
      await this.courier.load();
    }

    // 3. Toggle read on tap
    toggle(message: InboxMessage) {
      message.read ? this.courier.unreadMessage(message) : this.courier.readMessage(message);
    }

    // 4. Tear down
    ngOnDestroy() {
      this.sub?.unsubscribe();
    }
  }
  ```

  ```swift iOS theme={null}
  // 1. Start: adding a listener loads the first page (sign in first)
  let listener = await Courier.shared.addInboxListener(
    onMessagesChanged: { messages, canPaginate, feed in
      render(messages) // 2. Build your own UI
    }
  )

  // 3. Toggle read on tap
  if message.isRead {
    try await message.markAsUnread()
  } else {
    try await message.markAsRead()
  }

  // 4. Tear down
  listener.remove()
  ```

  ```kotlin Android theme={null}
  // 1. Start: adding a listener loads the first page (sign in first).
  //    addInboxListener is a suspend function; call it from a coroutine.
  lifecycleScope.launch {
    val listener = Courier.shared.addInboxListener(
      onMessagesChanged = { messages, canPaginate, feed ->
        render(messages) // 2. Build your own UI
      }
    )
    // 4. Tear down when the view goes away: listener.remove()
  }

  // 3. Toggle read on tap
  if (message.isRead) message.markAsUnread() else message.markAsRead()
  ```

  ```dart Flutter theme={null}
  // 1. Start: adding a listener loads the first page (sign in first)
  final listener = await Courier.shared.addInboxListener(
    onMessagesChanged: (messages, canPaginate, feed) {
      render(messages); // 2. Build your own UI
    },
  );

  // 3. Toggle read on tap
  message.isRead ? await message.markAsUnread() : await message.markAsRead();

  // 4. Tear down
  await listener.remove();
  ```

  ```jsx React Native theme={null}
  // 1. Start: adding a listener loads the first page (sign in first)
  const listener = await Courier.shared.addInboxListener({
    onMessagesChanged: (messages, canPaginate, feed) => {
      render(messages); // 2. Build your own UI
    },
  });

  // 3. Toggle read on tap
  message.isRead
    ? await Courier.shared.unreadMessage({ messageId: message.messageId })
    : await Courier.shared.readMessage({ messageId: message.messageId });

  // 4. Tear down
  await listener.remove();
  ```
</CodeGroup>

The listener (and the web `useCourier` hook) also reports loading, errors, unread count, and pagination state. See [Data and actions](#data-and-actions) for the full surface. On the mobile SDKs and Web Components, remove the listener when your view is torn down. The React, Vue, and Angular wrappers do this for you.

## Data and actions

The pattern above uses a subset of what each SDK exposes. Here is the full surface: the **data** you read (messages, unread count, pagination state) and the **actions** you call (mark read, archive, paginate, refresh). The built-in inbox calls these for you.

On the web, read the data from the `useCourier` hook (React and Vue), the `CourierInboxDatastore` (Web Components), or the `CourierService` (Angular). On mobile, an inbox listener pushes the data to you and the actions take a `messageId`.

<CodeGroup>
  ```jsx React theme={null}
  const { inbox } = useCourier();

  // DATA (values you read)

  // All feeds, keyed by datasetId (each: messages + pagination state)
  inbox.feeds;
  // Total unread across every feed
  inbox.totalUnreadCount;
  // Last error, if any
  inbox.error;
  // Each InboxMessage: messageId, title, body, preview, actions, data,
  // created, read, opened, archived, tags

  // ACTIONS (functions you call)

  // Mark read / unread
  inbox.readMessage(message);
  inbox.unreadMessage(message);
  // Track click / open
  inbox.clickMessage(message);
  inbox.openMessage(message);
  // Archive / unarchive
  inbox.archiveMessage(message);
  inbox.unarchiveMessage(message);
  // Mark every message read
  inbox.readAllMessages();
  // Set the page size
  inbox.setPaginationLimit(50);
  // Load the next page of a feed
  inbox.fetchNextPageOfMessages({ datasetId });
  // Define which feeds/tabs to load
  inbox.registerFeeds(feeds);
  // (Re)load the feeds
  inbox.load({ canUseCache: true });
  // Start the realtime connection
  inbox.listenForUpdates();
  ```

  ```js Web Components theme={null}
  import { Courier, CourierInboxDatastore } from "@trycourier/courier-ui-inbox";
  const store = CourierInboxDatastore.shared;

  // DATA (values you read)

  // All feeds, keyed by datasetId
  store.getDatasets();
  // One feed's dataset
  store.getDatasetById(datasetId);
  // Total unread across every feed
  store.totalUnreadCount;
  // React to changes
  store.addDataStoreListener(listener);
  // Stop reacting (cleanup)
  store.removeDataStoreListener(listener);

  // ACTIONS (functions you call)

  // Mark read / unread
  store.readMessage({ message });
  store.unreadMessage({ message });
  // Track click / open
  store.clickMessage({ message });
  store.openMessage({ message });
  // Archive / unarchive
  store.archiveMessage({ message });
  store.unarchiveMessage({ message });
  // Bulk: read all, archive all, archive read
  store.readAllMessages();
  store.archiveAllMessages();
  store.archiveReadMessages();
  // Inject a message into the store
  store.addMessage(message);
  // Load the next page of a feed
  store.fetchNextPageOfMessages({ datasetId });
  // Define which feeds/tabs to load
  store.registerFeeds(feeds);
  // (Re)load the feeds
  store.load({ canUseCache: true });
  // Badge counts without loading messages
  store.loadUnreadCountsForTabs(tabIds);
  // Start the realtime connection
  store.listenForUpdates();
  // Set the page size
  Courier.shared.paginationLimit = 50;
  ```

  ```ts Vue theme={null}
  const { inbox } = useCourier();
  const store = inbox.value; // inbox is a ref

  // DATA (values you read)

  // All feeds, keyed by datasetId (each: messages + pagination state)
  store.feeds;
  // Total unread across every feed
  store.totalUnreadCount;
  // Last error, if any
  store.error;

  // ACTIONS (functions you call)

  // Mark read / unread
  store.readMessage(message);
  store.unreadMessage(message);
  // Track click / open
  store.clickMessage(message);
  store.openMessage(message);
  // Archive / unarchive
  store.archiveMessage(message);
  store.unarchiveMessage(message);
  // Mark every message read
  store.readAllMessages();
  // Set the page size
  store.setPaginationLimit(50);
  // Load the next page of a feed
  store.fetchNextPageOfMessages({ datasetId });
  // Define which feeds/tabs to load
  store.registerFeeds(feeds);
  // (Re)load the feeds
  store.load({ canUseCache: true });
  // Start the realtime connection
  store.listenForUpdates();
  ```

  ```ts Angular theme={null}
  import { inject } from "@angular/core";
  import { CourierService } from "@trycourier/courier-angular";
  const courier = inject(CourierService);

  // DATA: subscribe to inbox$ (CourierInboxState)
  courier.inbox$.subscribe((state) => {
    // All feeds, keyed by datasetId
    state.feeds;
    // Total unread across every feed
    state.totalUnreadCount;
    // Last error, if any
    state.error;
  });

  // ACTIONS (take a bare message, not { message })

  // Mark read / unread
  courier.readMessage(message);
  courier.unreadMessage(message);
  // Track click / open
  courier.clickMessage(message);
  courier.openMessage(message);
  // Archive / unarchive
  courier.archiveMessage(message);
  courier.unarchiveMessage(message);
  // Mark every message read
  courier.readAllMessages();
  // Set the page size
  courier.setPaginationLimit(50);
  // Load the next page of a feed
  courier.fetchNextPageOfMessages({ datasetId });
  // Define which feeds/tabs to load
  courier.registerFeeds(feeds);
  // (Re)load the feeds
  courier.load({ canUseCache: true });
  // Start the realtime connection
  courier.listenForUpdates();

  // For the full datastore surface (bulk archive, direct dataset reads), use
  // CourierInboxDatastore.shared from "@trycourier/courier-ui-inbox":
  //   store.archiveAllMessages(); store.archiveReadMessages();
  //   store.getDatasets(); store.getDatasetById(datasetId); store.totalUnreadCount;
  ```

  ```swift iOS theme={null}
  // Subscribe for live data
  let listener = await Courier.shared.addInboxListener(
    onLoading: { isRefresh in /* spinner */ },
    onError: { error in /* handle */ },
    onUnreadCountChanged: { count in /* badge */ },
    onTotalCountChanged: { totalCount, feed in /* count */ },
    onMessagesChanged: { messages, canPaginate, feed in /* render */ },
    onPageAdded: { messages, canPaginate, isFirstPage, feed in /* append */ },
    onMessageEvent: { message, index, feed, event in /* .added / .read / .archived ... */ }
  )

  // DATA (values you read)

  // Messages in the main feed
  Courier.shared.feedMessages         // [InboxMessage]
  // Messages in the archived feed
  Courier.shared.archivedMessages     // [InboxMessage]
  // Current page size
  Courier.shared.inboxPaginationLimit // Int
  // Each InboxMessage: messageId, title, body, preview, subtitle,
  // isRead, isOpened, isArchived, created, actions

  // ACTIONS (async throws; take a messageId)

  // Mark read / unread
  try await Courier.shared.readMessage(messageId)
  try await Courier.shared.unreadMessage(messageId)
  // Track click / open
  try await Courier.shared.clickMessage(messageId)
  try await Courier.shared.openMessage(messageId)
  // Archive (no unarchive on iOS)
  try await Courier.shared.archiveMessage(messageId)
  // Mark every message read
  try await Courier.shared.readAllInboxMessages()
  // Set the page size
  Courier.shared.setPaginationLimit(50)
  // Load the next page (.feed or .archive)
  try await Courier.shared.fetchNextInboxPage(.feed)
  // Refresh from the server
  await Courier.shared.refreshInbox()
  // Stop listening (cleanup)
  listener.remove()
  // Or from a message instance:
  // markAsRead() / markAsUnread() / markAsOpened() / markAsClicked() / markAsArchived()
  ```

  ```kotlin Android theme={null}
  // Subscribe for live data (addInboxListener is a suspend function)
  lifecycleScope.launch {
    val listener = Courier.shared.addInboxListener(
      onLoading = { isRefresh -> /* spinner */ },
      onError = { error -> /* handle */ },
      onUnreadCountChanged = { count -> /* badge */ },
      onTotalCountChanged = { totalCount, feed -> /* count */ },
      onMessagesChanged = { messages, canPaginate, feed -> /* render */ },
      onPageAdded = { messages, canPaginate, isFirstPage, feed -> /* append */ },
      onMessageEvent = { message, index, feed, event -> /* InboxMessageEvent.ADDED / .READ ... */ }
    )
  }

  // DATA (values you read)

  // Messages in the main feed
  Courier.shared.feedMessages          // List<InboxMessage>
  // Messages in the archived feed
  Courier.shared.archivedMessages      // List<InboxMessage>
  // Current page size (1..100)
  Courier.shared.inboxPaginationLimit
  // Each InboxMessage: messageId, title, body, preview, subtitle,
  // isRead, isOpened, isArchived, created, actions

  // ACTIONS (suspend; take a messageId)

  // Mark read / unread
  Courier.shared.readMessage(messageId)
  Courier.shared.unreadMessage(messageId)
  // Track click / open
  Courier.shared.clickMessage(messageId)
  Courier.shared.openMessage(messageId)
  // Archive (no unarchive on Android)
  Courier.shared.archiveMessage(messageId)
  // Mark every message read
  Courier.shared.readAllInboxMessages()
  // Set the page size
  Courier.shared.inboxPaginationLimit = 50
  // Load the next page (.FEED or .ARCHIVE)
  Courier.shared.fetchNextInboxPage(InboxMessageFeed.FEED)
  // Refresh from the server
  Courier.shared.refreshInbox()
  // Tear down and reconnect
  Courier.shared.restartInbox()
  // Stop the connection
  Courier.shared.closeInbox()
  // Stop listening (cleanup)
  listener.remove()
  // Or from a message:
  // markAsRead() / markAsUnread() / markAsOpened() / markAsClicked() / markAsArchived()
  // Or from one of its actions:
  // message.actions?.firstOrNull()?.markAsClicked()
  ```

  ```dart Flutter theme={null}
  // Subscribe for live data
  final listener = await Courier.shared.addInboxListener(
    onLoading: (isRefresh) { /* spinner */ },
    onError: (error) { /* handle */ },
    onUnreadCountChanged: (count) { /* badge */ },
    onTotalCountChanged: (feed, totalCount) { /* count (note: feed first) */ },
    onMessagesChanged: (messages, canPaginate, feed) { /* render */ },
    onPageAdded: (messages, canPaginate, isFirstPage, feed) { /* append */ },
    onMessageEvent: (message, index, feed, event) { /* InboxMessageEvent.added / .read ... */ },
  );

  // DATA (Futures you await)

  // Messages in the main feed
  await Courier.shared.feedMessages;         // List<InboxMessage>
  // Messages in the archived feed
  await Courier.shared.archivedMessages;     // List<InboxMessage>
  // Current page size (default 32)
  await Courier.shared.inboxPaginationLimit;
  // Each InboxMessage: messageId, title, body, preview, subtitle,
  // isRead, isOpened, isArchived, created, actions

  // ACTIONS (async; take a messageId)

  // Mark read / unread
  await Courier.shared.readMessage(messageId: id);
  await Courier.shared.unreadMessage(messageId: id);
  // Track click / open
  await Courier.shared.clickMessage(messageId: id);
  await Courier.shared.openMessage(messageId: id);
  // Archive (no unarchive on Flutter)
  await Courier.shared.archiveMessage(messageId: id);
  // Mark every message read
  await Courier.shared.readAllInboxMessages();
  // Set the page size
  await Courier.shared.setInboxPaginationLimit(limit: 50);
  // Load the next page (InboxFeed.feed or InboxFeed.archive)
  await Courier.shared.fetchNextInboxPage(feed: InboxFeed.feed);
  // Refresh from the server
  await Courier.shared.refreshInbox();
  // Stop listening (cleanup)
  await listener.remove();
  // Or from a message:
  // await message.markAsRead() / markAsUnread() / markAsOpened() / markAsClicked() / markAsArchived()
  ```

  ```jsx React Native theme={null}
  // Subscribe for live data
  const listener = await Courier.shared.addInboxListener({
    onLoading: (isRefresh) => { /* spinner */ },
    onError: (error) => { /* handle */ },
    onUnreadCountChanged: (count) => { /* badge */ },
    onTotalCountChanged: (totalCount, feed) => { /* count */ },
    onMessagesChanged: (messages, canPaginate, feed) => { /* render */ },
    onPageAdded: (messages, canPaginate, isFirstPage, feed) => { /* append */ },
    onMessageEvent: (message, index, feed, event) => { /* "added" | "read" | "archived" ... */ },
  });

  // DATA (values you read)

  // Current page size (default 32)
  await Courier.shared.getInboxPaginationLimit();
  // Feed messages arrive via the listener's onMessagesChanged / onPageAdded
  // Each InboxMessage: messageId, title, body, preview, subtitle,
  // isRead, isOpened, isArchived, created, actions

  // ACTIONS (async; take { messageId })

  // Mark read / unread
  await Courier.shared.readMessage({ messageId });
  await Courier.shared.unreadMessage({ messageId });
  // Track click / open
  await Courier.shared.clickMessage({ messageId });
  await Courier.shared.openMessage({ messageId });
  // Archive (no unarchive on React Native)
  await Courier.shared.archiveMessage({ messageId });
  // Mark every message read
  await Courier.shared.readAllInboxMessages();
  // Set the page size
  await Courier.shared.setInboxPaginationLimit(50);
  // Load the next page ("feed" or "archive")
  await Courier.shared.fetchNextPageOfMessages({ inboxMessageFeed: "feed" });
  // Refresh from the server
  await Courier.shared.refreshInbox();
  // Stop listening (cleanup)
  await listener.remove();
  // Or from a message:
  // await message.markAsRead() / markAsUnread() / markAsOpened() / markAsClicked() / markAsArchived()
  ```
</CodeGroup>
