## Project chats

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

Returns the chats of one project from the current user's recent dialog list: the project's own chat and the chats nested in it. Use it for a project screen that shows the project's discussions next to its main chat.

## Parameters

| Parameter | Type | Required | Default | Description |
|----------|-----|:-----:|-----------|---------|
| `projectId` (path) | integer | yes | — | ID of the project chat, a positive integer. This is the `chatId` of the project row in the [collab list](/docs/chats/discovery/recent-collabs) |
| `limit` (query) | integer | no | 50 | Rows per page, 50 to 200. A smaller value is raised to 50 and a larger one is clamped to 200, and both adjustments are echoed in `meta` |
| `lastMessageDate` (query) | string | no | — | Cursor of the next page: the smallest `dateLastActivity` among the unpinned rows of the previous page. Format `YYYY-MM-DDTHH:MM:SS` with an offset or `Z`, without fractional seconds. Not passed on the first page |

Other parameters, including `format` and `unreadOnly`, and repeated parameters are rejected with `400 INVALID_PARAMS`.

**Pagination** — as in [recent dialogs in the v2 mode](/docs/chats/discovery/recent). The cursor of the next page is the smallest non-empty `dateLastActivity` among the **unpinned** rows; compare instants, not strings. The bound is inclusive: the row with the cursor date arrives again, so deduplicate by `chatId`. The list ends only at `hasNextPage: false`. If `hasNextPage` is `true` but there is no cursor or it equals the previous one, repeat the request with a larger `limit`, up to 200. Do not discard response rows: the cursor is computed over the whole response.

**The first page modifies data.** On the first read of a project, Bitrix24 creates this project's CoPilot chat for the key owner — once per project and only for a project member. For that reason a read-only key gets `403 WRITE_BLOCKED_READONLY_KEY` on a request without `lastMessageDate`, before the Bitrix24 call. Requests with `lastMessageDate` only read and are available to such a key — see [access rights](/docs/access-rights).

## Examples

### curl — personal key

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

### curl — OAuth application

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

### JavaScript — personal key

```javascript
const rows = new Map()
let limit = 50
let cursor
for (;;) {
  const url = new URL('https://vibecode.bitrix24.com/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 application

```javascript
const rows = new Map()
let limit = 50
let cursor
for (;;) {
  const url = new URL('https://vibecode.bitrix24.com/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
}
```

## Response fields

| Field | Type | Description |
|------|-----|---------|
| `success` | boolean | Always `true` on success |
| `data.recentItems` | array | List rows in the order Bitrix24 returns them. On the first page the row of the project itself arrives together with the nested chats |
| `data.recentItems[].dialogId` | string | Dialog ID, `chatXXX` |
| `data.recentItems[].chatId` | number | Chat ID, the deduplication key |
| `data.recentItems[].messageId` | number | ID of the last message |
| `data.recentItems[].pinned` | boolean | The chat is pinned for the current user |
| `data.recentItems[].unread` | boolean | The chat is manually marked as unread |
| `data.recentItems[].dateLastActivity` | string\|null | Instant of the last activity. The smallest value among the unpinned rows is the cursor of the next page |
| `data.chats` | array | Chat cards of the rows: `id`, `dialogId`, `name`, the current user's `role` and other chat fields |
| `data.chats[].type` | string | Chat type. `collab` for the project's own chat; for nested chats, the type of the chat itself, for example `chat` or `copilot` |
| `data.chats[].parentChatId` | number | `0` for the project's own chat, `projectId` for nested chats |
| `data.chats[].entityType` | string | `SONET_GROUP` for the project's own chat |
| `data.chats[].entityId` | string | ID of the project group for the project's own chat |
| `data.messages` | array | The chats' last messages |
| `data.users` | array | Authors of the last messages |
| `data.files` | array | Files of the last messages |
| `data.recentConfigs` | array | The list sections each chat is counted in: `chatId` and `sections` |
| `data.hasNextPage` | boolean | `false` — the end of the list |
| `data.sectionMeta` | object | Project details. Present only on the first page; responses to requests with `lastMessageDate` do not have the field |
| `data.sectionMeta.fixedChatIds` | array | ID of the project's own chat. An empty array if `projectId` is not a project chat |
| `data.sectionMeta.collabInfo` | object\|null | Project summary. `null` if `projectId` is not a project chat |
| `data.sectionMeta.collabInfo.collabId` | number | ID of the project group |
| `data.sectionMeta.collabInfo.guestCount` | number | Number of project guests |
| `data.sectionMeta.collabInfo.entities` | object | The project sections `tasks`, `files` and `calendar`. Each has `counter` — the counter, and `url` — the section address relative to the Bitrix24 account domain |
| `meta.requestedLimit` | number | The `limit` passed. Present together with `appliedLimit` only when the value was outside the range of 50 to 200 |
| `meta.appliedLimit` | number | The `limit` applied — only when it was clamped to a bound |

The `data` object has the same shape as in [recent dialogs in the v2 mode](/docs/chats/discovery/recent). Its other collections — `copilot`, `birthdayList`, `stickers`, `additionalMessages`, `messagesAutoDeleteConfigs`, `forwardSource` — arrive unchanged.

## Response example

The first page, with the main fields shown:

```json
{
  "success": true,
  "data": {
    "recentItems": [
      {
        "dialogId": "chat57",
        "chatId": 57,
        "messageId": 431,
        "type": "chat",
        "pinned": false,
        "unread": false,
        "dateUpdate": "2026-09-30T10:51:36+00:00",
        "dateLastActivity": "2026-09-30T10:51:36+00:00"
      },
      {
        "dialogId": "chat55",
        "chatId": 55,
        "messageId": 415,
        "type": "chat",
        "pinned": false,
        "unread": false,
        "dateUpdate": "2026-09-30T10:07:32+00:00",
        "dateLastActivity": "2026-08-10T14:31:46+00:00"
      },
      {
        "dialogId": "chat53",
        "chatId": 53,
        "messageId": 431,
        "type": "chat",
        "pinned": false,
        "unread": false,
        "dateUpdate": "2026-09-30T10:51:36+00:00",
        "dateLastActivity": "2026-09-30T10:51:36+00:00"
      }
    ],
    "chats": [
      {
        "id": 57,
        "dialogId": "chat57",
        "name": "Terms negotiation",
        "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": "Partner program launch",
        "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/" }
        }
      }
    }
  }
}
```

## Error response example

403 — the first page is requested with a read-only key:

```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"
    }
  }
}
```

## Errors

| HTTP | Code | Description |
|------|-----|---------|
| 400 | `INVALID_PARAMS` | `projectId` is not a positive integer. Checked before the Bitrix24 call |
| 400 | `INVALID_PARAMS` | A parameter other than `limit` and `lastMessageDate`, a repeated parameter, a non-numeric `limit`, or `lastMessageDate` not in the `YYYY-MM-DDTHH:MM:SS` format with an offset or `Z`, or with a nonexistent date or time (`2026-02-30`, `24:00`). Checked before the Bitrix24 call |
| 403 | `WRITE_BLOCKED_READONLY_KEY` | A request without `lastMessageDate` is sent with a read-only key. The key name and its mode are in `error.details` |
| 422 | `BITRIX_ERROR` | Bitrix24 returned an error; the portal code is in `error.b24Code`. `CHAT_NOT_FOUND` — there is no chat with this `projectId` |
| 404 | `ENTITY_NOT_FOUND` | Bitrix24 returned "not found"; the portal code is in `error.b24Code` |
| 403 | `BITRIX_ACCESS_DENIED` | The key owner has no access to the project chat |
| 403 | `SCOPE_DENIED` | The API key does not have the `im` scope |
| 401 | `TOKEN_MISSING` | The API key has no Bitrix24 tokens configured |
| 502 | `BITRIX_UNAVAILABLE` | Bitrix24 is unavailable or returned a server error |

Full list of common API errors — [Errors](/docs/errors).

## Known specifics

**A chat that is not a project is an empty list, not an error.** The ID of a group chat not linked to a project returns `200` with empty collections and `hasNextPage: false`. Such a response does not reveal a mistake in `projectId`: a project is indicated by a non-empty `sectionMeta.fixedChatIds` on the first page.

**The refusal for a read-only key does not depend on the project state.** The first page is closed to such a key even when the project's CoPilot chat already exists.

## See also

- [Collab list](/docs/chats/discovery/recent-collabs)
- [Recent dialogs](/docs/chats/discovery/recent)
- [External chats of a section](/docs/chats/discovery/recent-external)
- [Chat discovery](/docs/chats/discovery)
