
## Workday history

`GET /v1/workday/records`

Returns an employee's workday history: start and end times, seconds worked, break durations, and an offset calculated for each record from the current IANA `TIME_ZONE` identity in the employee profile. Records are selected by the required employee identifier and a period; without a period the window covers the last 7 days.

This endpoint requires both `timeman` and one of `user_brief`, `user_basic`, or `user`. An empty page is returned without an additional profile lookup. If the current IANA profile zone cannot be retrieved or its historical rules cannot yield an offset for a non-empty page, the endpoint returns `502 BITRIX_UNAVAILABLE` with no partial data. The API cannot detect a zone reassignment after record creation, so that case does not produce a 502.

## Parameters

| Parameter | Type | Required | Description |
|----------|-----|--------------|---------|
| `userId` (query) | number | yes | Employee identifier, an integer greater than zero. Your own identifier — [`GET /v1/users/me`](/docs/entities/users/me), the employee list — [`GET /v1/users`](/docs/entities/users/list) |
| `from` (query) | string | no | Start of the period, ISO-8601 with an explicit offset or `Z` — for example `2026-06-01T00:00:00Z`. Filters by the day start time. A date without a time returns `400`. Encode the plus sign in the offset as `%2B`; otherwise the value arrives with a space instead of the sign and is not recognized |
| `to` (query) | string | no | End of the period in the same format. A `from` later than `to` returns `400` |
| `limit` (query) | number | no | Records per page, from 1 to 50. Defaults to `50` |
| `offset` (query) | number | no | Offset from the start of the selection, a non-negative integer. Defaults to `0`. Cannot be combined with `page` |
| `page` (query) | number | no | Page number starting from one, equivalent to `offset` with the value `(page − 1) × limit`. Cannot be combined with `offset` |
| `order` (query) | string | no | Order by the day start time: `desc` — newest first, `asc` — oldest first. Defaults to `desc` |

Both ends of the period are optional: if neither `from` nor `to` is passed, the selection is limited to the last 7 days from the moment of the request. A query parameter outside this list returns `400 INVALID_PARAMS` with the name of the passed key and the list of accepted ones.

## Examples

### curl — personal key

```bash
curl -H "X-Api-Key: YOUR_API_KEY" \
  "https://vibecode.bitrix24.com/v1/workday/records?userId=1&from=2020-01-01T00:00:00Z&limit=3&order=asc"
```

### curl — another employee's records

```bash
curl -H "X-Api-Key: YOUR_API_KEY" \
  "https://vibecode.bitrix24.com/v1/workday/records?userId=503&from=2020-01-01T00:00:00Z&limit=3&order=asc"
```

### curl — OAuth application

```bash
curl -H "X-Api-Key: YOUR_APP_KEY" \
  -H "Authorization: Bearer USER_SESSION_TOKEN" \
  "https://vibecode.bitrix24.com/v1/workday/records?userId=1&from=2020-01-01T00:00:00Z&limit=3&order=asc"
```

### JavaScript — personal key

```javascript
const params = new URLSearchParams({
  userId: '1',
  from: '2020-01-01T00:00:00Z',
  limit: '3',
  order: 'asc',
})
const res = await fetch(`https://vibecode.bitrix24.com/v1/workday/records?${params}`, {
  headers: { 'X-Api-Key': 'YOUR_API_KEY' },
})
const { data, meta } = await res.json()
for (const record of data) {
  console.log(record.startTime, '— seconds worked:', record.duration)
}
console.log('More records available:', meta.hasMore)
```

### JavaScript — OAuth application

```javascript
const params = new URLSearchParams({
  userId: '1',
  from: '2020-01-01T00:00:00Z',
  limit: '3',
  order: 'asc',
})
const res = await fetch(`https://vibecode.bitrix24.com/v1/workday/records?${params}`, {
  headers: {
    'X-Api-Key': 'YOUR_APP_KEY',
    'Authorization': 'Bearer USER_SESSION_TOKEN',
  },
})
const { data, meta } = await res.json()
```

## Response fields

| Field | Type | Description |
|------|-----|---------|
| `success` | boolean | `true` on a successful response |
| `data` | array | Array of workday records. An empty array when no record falls within the period |
| `data[].id` | number | Workday record identifier |
| `data[].userId` | number | Identifier of the employee the record belongs to |
| `data[].startTime` | string | Day start time, ISO-8601. The string keeps the account offset and is not rewritten to the employee's time zone |
| `data[].endTime` | string \| null | Day end time in the same format. On a day that is still open the field is absent or arrives as `null` |
| `data[].duration` | number \| null | Time worked during the day, in seconds. On a day that is still open the field is absent or arrives as `null` |
| `data[].breakLength` | number \| null | Total break duration for the day, in seconds. On a day that is still open the field is absent or arrives as `null` |
| `data[].state.status` | string | Record state in lowercase. On a finished day it is `closed` |
| `data[].state.recommendedCloseTime` | string \| null | Recommended day close time. On verified records `null` |
| `data[].isApproved` | boolean \| null | Whether the workday record is approved. On a day that is still open the field is absent or arrives as `null` |
| `data[].tzOffset` | number | Required seconds east of UTC calculated for the `startTime` instant using the historical rules of the current IANA `TIME_ZONE` identity in the employee profile. It is not proof that this zone was assigned when the record was created |
| `meta.hasMore` | boolean | `true` when the page is filled up to `limit` and more records follow |
| `meta.total` | number | Number of records in the selection — the sum of `offset` and the page length. Arrives only when the page is shorter than `limit` |
| `meta.from` | string | The lower bound of the applied period. Present when that bound is set — either by the `from` passed in or by the substituted last-7-days window |
| `meta.to` | string | The upper bound of the applied period. Absent for a period left open at the top (only `from` passed) |

## Response example

The first three records from oldest to newest, `?userId=1&from=2020-01-01T00:00:00Z&limit=3&order=asc`:

```json
{
  "success": true,
  "data": [
    {
      "id": 1,
      "userId": 1,
      "startTime": "2020-04-23T13:42:10+00:00",
      "endTime": "2020-04-23T17:53:26+00:00",
      "duration": 12999,
      "breakLength": 2077,
      "state": { "status": "closed", "recommendedCloseTime": null },
      "isApproved": true,
      "tzOffset": 7200
    },
    {
      "id": 5,
      "userId": 1,
      "startTime": "2020-04-24T09:16:12+00:00",
      "endTime": "2020-04-24T16:30:13+00:00",
      "duration": 26041,
      "breakLength": 0,
      "state": { "status": "closed", "recommendedCloseTime": null },
      "isApproved": true,
      "tzOffset": 7200
    },
    {
      "id": 7,
      "userId": 1,
      "startTime": "2020-04-27T09:04:11+00:00",
      "endTime": "2020-04-27T18:00:00+00:00",
      "duration": 32149,
      "breakLength": 0,
      "state": { "status": "closed", "recommendedCloseTime": null },
      "isApproved": true,
      "tzOffset": 7200
    }
  ],
  "meta": { "hasMore": true, "from": "2020-01-01T00:00:00Z" }
}
```

The last page of the same selection, `?userId=1&from=2020-01-01T00:00:00Z&offset=40&order=asc` — fewer records than `limit` arrived, so `total` appears in `meta`:

```json
{
  "success": true,
  "data": [
    {
      "id": 163,
      "userId": 1,
      "startTime": "2024-07-24T17:34:51+00:00",
      "endTime": "2024-07-25T02:35:00+00:00",
      "duration": 32409,
      "breakLength": 0,
      "state": { "status": "closed", "recommendedCloseTime": null },
      "isApproved": true,
      "tzOffset": 7200
    },
    {
      "id": 165,
      "userId": 1,
      "startTime": "2024-08-26T12:34:47+00:00",
      "endTime": "2024-08-26T12:58:15+00:00",
      "duration": 1408,
      "breakLength": 0,
      "state": { "status": "closed", "recommendedCloseTime": null },
      "isApproved": true,
      "tzOffset": 7200
    },
    {
      "id": 167,
      "userId": 1,
      "startTime": "2024-09-05T12:07:30+00:00",
      "endTime": "2025-03-27T14:53:08+00:00",
      "duration": 17548827,
      "breakLength": 311,
      "state": { "status": "closed", "recommendedCloseTime": null },
      "isApproved": true,
      "tzOffset": 7200
    },
    {
      "id": 171,
      "userId": 1,
      "startTime": "2026-05-05T09:52:51+00:00",
      "endTime": "2026-05-05T09:53:37+00:00",
      "duration": 20,
      "breakLength": 26,
      "state": { "status": "closed", "recommendedCloseTime": null },
      "isApproved": true,
      "tzOffset": 7200
    },
    {
      "id": 173,
      "userId": 1,
      "startTime": "2026-05-05T09:55:38+00:00",
      "endTime": "2026-05-05T09:55:39+00:00",
      "duration": 1,
      "breakLength": 0,
      "state": { "status": "closed", "recommendedCloseTime": null },
      "isApproved": true,
      "tzOffset": 7200
    },
    {
      "id": 175,
      "userId": 1,
      "startTime": "2026-05-05T10:07:02+00:00",
      "endTime": "2026-05-05T10:07:06+00:00",
      "duration": 2,
      "breakLength": 2,
      "state": { "status": "closed", "recommendedCloseTime": null },
      "isApproved": true,
      "tzOffset": 7200
    }
  ],
  "meta": { "hasMore": false, "total": 46, "from": "2020-01-01T00:00:00Z" }
}
```

## Error response example

400 — a required parameter is missing:

```json
{
  "success": false,
  "error": {
    "code": "MISSING_REQUIRED_PARAMS",
    "message": "userId is required. Bitrix24 refuses this method without it; get the id from GET /v1/users/me."
  }
}
```

## Errors

| HTTP | Code | Description |
|------|-----|---------|
| 400 | `MISSING_REQUIRED_PARAMS` | `userId` is missing |
| 400 | `INVALID_PARAMS` | Parameter validation failed: an invalid `userId`, `limit` outside 1..50, a date without an explicit offset or one that does not exist in the calendar, `from` later than `to`, `offset` together with `page`, a parameter passed twice, an offset above 999,999,999, or an unknown query parameter |
| 401 | `MISSING_API_KEY` | The `X-Api-Key` header is missing |
| 401 | `INVALID_API_KEY` | Invalid API key |
| 401 | `KEY_EXPIRED` | The API key has expired |
| 401 | `TOKEN_MISSING` | The key has no OAuth tokens configured for the portal |
| 402 | `ACCOUNT_FROZEN` | The portal balance is frozen |
| 403 | `SCOPE_DENIED` | The key does not satisfy the compound requirement: it needs `timeman` plus at least one of `user_brief`, `user_basic`, or `user` |
| 403 | `BITRIX_ACCESS_DENIED` | Bitrix24 denied access — including when the key is not allowed to read the specified employee's time sheet |
| 409 | `TIMEMAN_MODULE_NOT_ENABLED` | Bitrix24 did not accept the method. Call [`GET /v1/workday/status`](/docs/workday/status): the same refusal means the time tracking module is disabled on the portal; a `200` response means the portal does not return workday history while the current day state stays available |
| 422 | `BITRIX_ERROR` | Other Bitrix24 refusals — the text is in `message` |
| 429 | `RATE_LIMITED` | The rate limit of this endpoint is exceeded — 30 requests per minute per key. For an application key the count is kept separately for each employee |
| 429 | `RATE_LIMITED` | The Bitrix24 portal request limit is exceeded |
| 502 | `BITRIX_UNAVAILABLE` | The Bitrix24 portal is unavailable, or the current IANA profile zone or the record offset derived from it cannot be determined reliably; an unknown former zone does not cause this error |

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

## Known specifics

- **The key does not restrict permissions to the caller's identity.** A personal key `vibe_api_…` accesses the Bitrix24 account through an inbound webhook and reads the time sheet with the rights of the Bitrix24 employee the webhook was issued under — that is, the rights of the key creator, not of whoever uses the key. The owner of such a key can enumerate `userId` and export the clock-in and clock-out times of the whole company. An application key `vibe_app_…` works differently — the request goes with the rights of the employee who opened the session. Bitrix24, not the platform, decides who may read another employee's time sheet: the Bitrix24 account administrator or the employee's direct manager.
- **The response names the period it applied.** `meta.from` and `meta.to` are present even when no period was passed: otherwise a `meta.total` of `0` cannot be told apart from "this employee has no records at all", though it only means "none in the last 7 days". For a period left open on one side, only the bound that is set comes back.
- **A parameter must not be passed twice.** `?userId=1&userId=2` returns `400` instead of silently picking one of the values.
- **`meta.total` arrives only on a short page.** On a full page the field is absent from the response entirely, so a handler that reads it unconditionally gets `undefined`. The value is correct at the time of the response: with `order=desc` a record added between two requests shifts the output window.
- **The offset in `startTime` and `endTime` is not the employee's time zone.** Both strings keep the account offset and are not rewritten. `tzOffset` is calculated for the absolute `startTime` instant using the historical rules of the current IANA `TIME_ZONE` identity in the employee profile. To get local time in that zone, parse the absolute instant from `startTime` and apply `tzOffset`. If another IANA zone was assigned after record creation, the API does not know the former assignment: the returned value belongs to the current profile zone and is not proof of the zone assigned at that time.
- **Durations arrive in seconds.** `duration` and `breakLength` are integer numbers of seconds. The related [`GET /v1/workday/status`](/docs/workday/status) returns durations as a string of the form `HH:MM:SS`, so parsing does not carry over from one page to the other.
- **The selection is limited to the employee and the day start time.** The endpoint has no other axes: you cannot narrow the selection by end time, duration or the approval flag, and sorting is by start time only. The needed slice is assembled from the returned records on your side.
- **The whole history is traversed in pages of 50 records.** The endpoint does not return more than 50 records per request; deeper pages are reached with `offset` or `page`. The end of the output is signaled by `meta.hasMore` with the value `false`. A request with an offset beyond the end of the selection returns an empty array, and `meta.total` does not arrive in such a response.

## See also

- [Current status](/docs/workday/status)
- [Open a workday](/docs/workday/open)
- [Close a workday](/docs/workday/close)
- [Pause](/docs/workday/pause)
- [Tracking settings](/docs/workday/settings)
- [Work schedule](/docs/workday/schedule)
- [Workday](/docs/workday)
