## Чаты проекта

`GET /v1/chats/projects/:projectId/recent`

Возвращает чаты одного проекта из списка последних диалогов текущего пользователя: собственный чат проекта и вложенные в него чаты. Подходит для экрана проекта, где рядом с общим чатом показаны его обсуждения.

## Параметры

| Параметр | Тип | Обяз. | По умолч. | Описание |
|----------|-----|:-----:|-----------|---------|
| `projectId` (path) | integer | да | — | ID чата проекта, целое от 1. Это `chatId` строки проекта в [списке коллаб](/docs/chats/discovery/recent-collabs) |
| `limit` (query) | integer | нет | 50 | Строк на странице, от 50 до 200. Меньшее значение поднимается до 50, большее срезается до 200, оба раза с эхом в `meta` |
| `lastMessageDate` (query) | string | нет | — | Курсор следующей страницы — наименьшая `dateLastActivity` среди незакреплённых строк предыдущей страницы. Формат `YYYY-MM-DDTHH:MM:SS` со смещением или `Z`, без долей секунды. На первой странице не передаётся |

Другие параметры, в том числе `format` и `unreadOnly`, и повторы отклоняются с `400 INVALID_PARAMS`.

**Обход** — как у [последних диалогов в режиме v2](/docs/chats/discovery/recent). Курсор следующей страницы — наименьшая непустая `dateLastActivity` среди **незакреплённых** строк, сравнивайте моменты времени, а не строки. Граница включительная: строка с датой курсора приходит повторно, повторы снимайте по `chatId`. Конец списка — только `hasNextPage: false`. Если при `hasNextPage: true` курсора нет или он равен прежнему, повторите запрос с бо́льшим `limit`, вплоть до 200. Строк ответа не отбрасывайте: курсор считается по всему ответу.

**Первая страница изменяет данные.** При первом чтении проекта Битрикс24 создаёт для владельца ключа чат CoPilot этого проекта — один раз на проект и только участнику проекта. Поэтому ключ только для чтения получает на запрос без `lastMessageDate` ответ `403 WRITE_BLOCKED_READONLY_KEY` до обращения к Битрикс24. Запросы с `lastMessageDate` только читают и такому ключу доступны. Подробнее — [права доступа](/docs/access-rights).

## Примеры

### curl — личный ключ

```bash
curl "https://vibecode.bitrix24.tech/v1/chats/projects/53/recent?limit=50" \
  -H "X-Api-Key: YOUR_API_KEY"
```

### curl — OAuth-приложение

```bash
curl "https://vibecode.bitrix24.tech/v1/chats/projects/53/recent?limit=50" \
  -H "X-Api-Key: YOUR_APP_KEY" \
  -H "Authorization: Bearer USER_SESSION_TOKEN"
```

### JavaScript — личный ключ

```javascript
const rows = new Map()
let limit = 50
let cursor
for (;;) {
  const url = new URL('https://vibecode.bitrix24.tech/v1/chats/projects/53/recent')
  url.searchParams.set('limit', String(limit))
  if (cursor) url.searchParams.set('lastMessageDate', cursor)
  const res = await fetch(url, { headers: { 'X-Api-Key': 'YOUR_API_KEY' } })
  const { data } = await res.json()
  for (const item of data.recentItems) rows.set(item.chatId, item)
  if (!data.hasNextPage) break
  const dates = data.recentItems.filter((i) => !i.pinned && i.dateLastActivity).map((i) => i.dateLastActivity)
  const next = dates.length ? dates.reduce((a, b) => (Date.parse(a) <= Date.parse(b) ? a : b)) : undefined
  if (!next || (cursor && Date.parse(next) >= Date.parse(cursor))) {
    if (limit === 200) throw new Error('Recent-list cursor did not advance; restart with a full refresh')
    limit = Math.min(limit * 2, 200)
    continue
  }
  cursor = next
  limit = 50
}
```

### JavaScript — OAuth-приложение

```javascript
const rows = new Map()
let limit = 50
let cursor
for (;;) {
  const url = new URL('https://vibecode.bitrix24.tech/v1/chats/projects/53/recent')
  url.searchParams.set('limit', String(limit))
  if (cursor) url.searchParams.set('lastMessageDate', cursor)
  const res = await fetch(url, {
    headers: {
      'X-Api-Key': 'YOUR_APP_KEY',
      'Authorization': 'Bearer USER_SESSION_TOKEN',
    },
  })
  const { data } = await res.json()
  for (const item of data.recentItems) rows.set(item.chatId, item)
  if (!data.hasNextPage) break
  const dates = data.recentItems.filter((i) => !i.pinned && i.dateLastActivity).map((i) => i.dateLastActivity)
  const next = dates.length ? dates.reduce((a, b) => (Date.parse(a) <= Date.parse(b) ? a : b)) : undefined
  if (!next || (cursor && Date.parse(next) >= Date.parse(cursor))) {
    if (limit === 200) throw new Error('Recent-list cursor did not advance; restart with a full refresh')
    limit = Math.min(limit * 2, 200)
    continue
  }
  cursor = next
  limit = 50
}
```

## Поля ответа

| Поле | Тип | Описание |
|------|-----|---------|
| `success` | boolean | Всегда `true` при успехе |
| `data.recentItems` | array | Строки списка в порядке портала. На первой странице вместе с вложенными чатами приходит строка самого проекта |
| `data.recentItems[].dialogId` | string | ID диалога, `chatXXX` |
| `data.recentItems[].chatId` | number | ID чата — ключ для снятия повторов |
| `data.recentItems[].messageId` | number | ID последнего сообщения |
| `data.recentItems[].pinned` | boolean | Чат закреплён у текущего пользователя |
| `data.recentItems[].unread` | boolean | Чат вручную отмечен непрочитанным |
| `data.recentItems[].dateLastActivity` | string\|null | Момент последней активности. Наименьшее значение среди незакреплённых строк — курсор следующей страницы |
| `data.chats` | array | Карточки чатов строк: `id`, `dialogId`, `name`, `role` текущего пользователя и другие поля чата |
| `data.chats[].type` | string | Тип чата. У собственного чата проекта — `collab`, у вложенных — тип самого чата, например `chat` или `copilot` |
| `data.chats[].parentChatId` | number | У собственного чата проекта — `0`, у вложенных чатов — `projectId` |
| `data.chats[].entityType` | string | У собственного чата проекта — `SONET_GROUP` |
| `data.chats[].entityId` | string | У собственного чата проекта — ID группы проекта |
| `data.messages` | array | Последние сообщения чатов |
| `data.users` | array | Авторы последних сообщений |
| `data.files` | array | Файлы последних сообщений |
| `data.recentConfigs` | array | Разделы списка, в которых учитывается каждый чат: `chatId` и `sections` |
| `data.hasNextPage` | boolean | `false` — конец списка |
| `data.sectionMeta` | object | Сведения о проекте. Приходит только на первой странице, в ответах на запросы с `lastMessageDate` поля нет |
| `data.sectionMeta.fixedChatIds` | array | ID собственного чата проекта. Пустой массив, если `projectId` — не чат проекта |
| `data.sectionMeta.collabInfo` | object\|null | Сводка проекта. `null`, если `projectId` — не чат проекта |
| `data.sectionMeta.collabInfo.collabId` | number | ID группы проекта |
| `data.sectionMeta.collabInfo.guestCount` | number | Число гостей проекта |
| `data.sectionMeta.collabInfo.entities` | object | Разделы проекта `tasks`, `files` и `calendar`. У каждого `counter` — счётчик и `url` — адрес раздела относительно домена портала |
| `meta.requestedLimit` | number | Переданный `limit`. Приходит вместе с `appliedLimit`, только когда значение вышло за диапазон от 50 до 200 |
| `meta.appliedLimit` | number | Применённый `limit` — только если он был приведён к границе |

Объект `data` — той же формы, что у [последних диалогов в режиме v2](/docs/chats/discovery/recent). Остальные его коллекции — `copilot`, `birthdayList`, `stickers`, `additionalMessages`, `messagesAutoDeleteConfigs`, `forwardSource` — приходят без изменений.

## Пример ответа

Первая страница, показаны основные поля:

```json
{
  "success": true,
  "data": {
    "recentItems": [
      {
        "dialogId": "chat57",
        "chatId": 57,
        "messageId": 431,
        "type": "chat",
        "pinned": false,
        "unread": false,
        "dateUpdate": "2026-09-30T10:51:36+03:00",
        "dateLastActivity": "2026-09-30T10:51:36+03:00"
      },
      {
        "dialogId": "chat55",
        "chatId": 55,
        "messageId": 415,
        "type": "chat",
        "pinned": false,
        "unread": false,
        "dateUpdate": "2026-09-30T10:07:32+03:00",
        "dateLastActivity": "2026-08-10T14:31:46+03:00"
      },
      {
        "dialogId": "chat53",
        "chatId": 53,
        "messageId": 431,
        "type": "chat",
        "pinned": false,
        "unread": false,
        "dateUpdate": "2026-09-30T10:51:36+03:00",
        "dateLastActivity": "2026-09-30T10:51:36+03:00"
      }
    ],
    "chats": [
      {
        "id": 57,
        "dialogId": "chat57",
        "name": "Согласование условий",
        "type": "chat",
        "parentChatId": 53,
        "entityType": "",
        "entityId": "",
        "owner": 1,
        "role": "owner"
      },
      {
        "id": 55,
        "dialogId": "chat55",
        "name": "Session #2",
        "type": "copilot",
        "parentChatId": 53,
        "entityType": "SONET_PROJECT_COPILOT",
        "entityId": "3",
        "owner": 1,
        "role": "owner"
      },
      {
        "id": 53,
        "dialogId": "chat53",
        "name": "Запуск партнёрской программы",
        "type": "collab",
        "parentChatId": 0,
        "entityType": "SONET_GROUP",
        "entityId": "3",
        "owner": 1,
        "role": "owner"
      }
    ],
    "recentConfigs": [
      { "chatId": 57, "sections": ["default", "chat", "collabDefault", "collabChat"] },
      { "chatId": 55, "sections": ["default", "copilot", "collabDefault"] },
      { "chatId": 53, "sections": ["default", "collab"] }
    ],
    "hasNextPage": false,
    "sectionMeta": {
      "fixedChatIds": [53],
      "collabInfo": {
        "guestCount": 0,
        "collabId": 3,
        "entities": {
          "tasks": { "counter": 0, "url": "/workgroups/group/3/tasks/" },
          "files": { "counter": 0, "url": "/workgroups/group/3/disk/path/?c_element=files_button" },
          "calendar": { "counter": 0, "url": "/workgroups/group/3/calendar/" }
        }
      }
    }
  }
}
```

## Пример ответа при ошибке

403 — первая страница запрошена ключом только для чтения:

```json
{
  "success": false,
  "error": {
    "code": "WRITE_BLOCKED_READONLY_KEY",
    "message": "Key is in read-only mode. Switch the key to read and write in /keys to allow Bitrix24 data changes.",
    "details": {
      "method": "im.v2.Recent.load",
      "keyName": "Reports dashboard",
      "currentMode": "READONLY",
      "switchUrl": "/keys"
    }
  }
}
```

## Ошибки

| HTTP | Код | Описание |
|------|-----|---------|
| 400 | `INVALID_PARAMS` | `projectId` не является целым числом от 1. Проверяется до обращения к Битрикс24 |
| 400 | `INVALID_PARAMS` | Параметр вне `limit` и `lastMessageDate`, повтор параметра, нечисловой `limit` или `lastMessageDate` не в формате `YYYY-MM-DDTHH:MM:SS` со смещением или `Z` либо с несуществующей датой или временем (`2026-02-30`, `24:00`). Проверяется до обращения к Битрикс24 |
| 403 | `WRITE_BLOCKED_READONLY_KEY` | Запрос без `lastMessageDate` отправлен ключом только для чтения. Имя ключа и его режим — в `error.details` |
| 422 | `BITRIX_ERROR` | Битрикс24 вернул ошибку, код портала — в `error.b24Code`. `CHAT_NOT_FOUND` — чата с таким `projectId` нет |
| 404 | `ENTITY_NOT_FOUND` | Битрикс24 вернул «не найдено», код портала — в `error.b24Code` |
| 403 | `BITRIX_ACCESS_DENIED` | У владельца ключа нет доступа к чату проекта |
| 403 | `SCOPE_DENIED` | API-ключ не имеет скоупа `im` |
| 401 | `TOKEN_MISSING` | API-ключ не имеет настроенных токенов Битрикс24 |
| 502 | `BITRIX_UNAVAILABLE` | Битрикс24 недоступен или вернул ошибку сервера |

Полный список общих ошибок API — [Ошибки](/docs/errors).

## Известные особенности

**Чат, который не проект, — пустой список, а не ошибка.** ID группового чата, не связанного с проектом, отвечает `200` с пустыми коллекциями и `hasNextPage: false`. Ошибку в `projectId` такой ответ не покажет: признак проекта — непустой `sectionMeta.fixedChatIds` на первой странице.

**Отказ ключу только для чтения не зависит от состояния проекта.** Первая страница закрыта такому ключу и тогда, когда чат CoPilot проекта уже создан.

## Смотрите также

- [Список коллаб](/docs/chats/discovery/recent-collabs)
- [Последние диалоги](/docs/chats/discovery/recent)
- [Внешние чаты раздела](/docs/chats/discovery/recent-external)
- [Поиск чатов](/docs/chats/discovery)
