---
title: "Batch unread message counts per room for a set of users"
method: POST
path: "/v2/apps/{appId}/users/unread-counts"
tags: ["Chats"]
---

# Batch unread message counts per room for a set of users

`POST /v2/apps/{appId}/users/unread-counts`

## Request

- Base URL: `https://api.chat.ethora.com`
- URL: `https://api.chat.ethora.com/v2/apps/{appId}/users/unread-counts`
- Auth: one of:
  - HTTP bearer
  - HTTP bearer

Returns, for each requested user, how many unread messages they have
per chat room in the target app. Built for backend / CRON flows -
e.g. a scheduled "you have unread messages" digest email - where your
backend iterates its own users and asks Ethora for their unread state.

**Why POST, not GET:** the input is an array of user ids, which would
not fit reliably in a query string. The call is otherwise a plain
synchronous read - it returns the counts immediately, nothing is
queued or processed in the background.

**How unread is derived (read-only, no client change required):**
per room, a cutoff is chosen as the user's `lastViewed` marker for
that room (persisted by the chat clients in ejabberd private storage),
or - if the user never opened the room - the time they were added to
it. Unread = messages in that room newer than the cutoff, excluding
the user's own. Messages are counted from the Mongo archive
(`*_chat_archive.messages`, the same store as `/messages/search`), so
this load never touches the live XMPP database.

**Modes:**
- `count` (default): exact per-room counts, each bounded by `cap`
  (a room with more than `cap` unread reports `cap` with
  `capped: true`, i.e. "99+").
- `flag`: skips exact counting and only reports whether each room has
  any unread (`unreadCount` collapses to 1). Cheapest option for very
  large / hot deployments.

**Scope & limits:** all `userIds` must belong to `appId`. Identifiers
may be a user's uuid, Mongo `_id`, or xmppUsername. Max 200 users per
request - split larger runs into multiple calls. Ids that don't
resolve to a user in the app are returned in `notFound` rather than
failing the request.

**Accuracy note:** this is a best-effort count for nudge/notification
use. It can differ slightly from the in-app badge (which zeroes the
active room live and is multi-device-synced only on app open). For a
periodic digest that difference is immaterial.

## Path parameters

- `appId` string, required

## Request body

- object
  - `userIds` string[], required: User identifiers (uuid / Mongo _id / xmppUsername), all within appId.
  - `mode` 'count' | 'flag': `count` = exact per-room counts (capped); `flag` = presence only.
  - `cap` integer: Per-room count ceiling. Counts at or above this report capped=true.

## Response `200`

Per-user unread breakdown. Rooms with zero unread are omitted from
each user's `rooms` array.
```
{
  appId: string,
  mode: 'count' | 'flag',
  cap: number,
  markerSource: 'private-storage' | 'membership-fallback',
  results: [
    {
      userId: string,        // the identifier you passed in
      uuid: string | null,
      xmppUsername: string | null,
      total: number,         // sum of per-room unreadCount
      hasUnread: boolean,
      unreadRoomCount: number,
      capped: boolean,       // any room hit the cap
      rooms: [
        { chatId: string, unreadCount: number, capped: boolean }
      ]
    }
  ],
  notFound: string[]         // ids that didn't resolve to a user
}
```

## Other responses

- `400`: Bad Request
- `401`: Unauthorized
- `403`: Caller has no access to the target app. ``` { error: string, code: 'FORBIDDEN' } ```
- `404`: Not Found
- `422`: Validation error (empty `userIds`, more than 200 ids, invalid mode/cap). ``` { error: string, code: 'VALIDATION_ERROR' } ```
- `500`: Internal Server Error

---

[API](https://skmtc.dev/ethora/apis/platform-api.md) · [All operations](https://skmtc.dev/ethora/apis/platform-api/llms.txt) · [OpenAPI document](https://skmtc.dev/ethora/apis/platform-api/revisions/01b6e64378ee?raw)
