# Pagination

> Cursor pagination with limit, cursor and updated_since on TimeTracking list endpoints, which endpoints support it, and how to run incremental syncs.

URL: https://timetracking.tech/developers/api/pagination
Last updated: 2026-10-24

API v1.1 uses **keyset (cursor) pagination**.

## Parameters

| Parameter | Type | Default | Notes |
|---|---|---|---|
| `limit` | integer 1–500 | 100 | Page size |
| `cursor` | opaque string | — | The `next_cursor` from the previous page. Do not build or parse it |
| `updated_since` | ISO 8601 date-time **with offset** (`2026-10-23T19:00:00Z`, `2026-10-23T21:00:00+02:00`) | — | Only rows changed at or after this instant (`created_at` for claims and export runs) |

Response shape:

```json
{ "data": { "items": [ … ], "next_cursor": "eyJ1Ijoi…" } }
```

`next_cursor` is `null` on the last page. Rows are ordered by `(updated_at, id)`, or `(created_at, id)` for claims and export runs. Rows inserted while you page therefore don't shift earlier pages. An invalid cursor returns 422 `{"code":"invalid","message":"Invalid cursor"}`.

## Endpoints

| Endpoint | Paginated | Required | Filters |
|---|---|---|---|
| `GET /days` | always | `from`, `to` (`YYYY-MM-DD`) | `status` (comma list, e.g. `approved,exported`), `person_id`, `placement_id`, `client_id`, `updated_since` |
| `GET /claims` | always | `from`, `to` | `placement_id`, `source`, `updated_since` |
| `GET /exports/runs` | always | — | `updated_since` (on `created_at`) |
| `GET /people` | **only when** `limit`, `cursor` or `updated_since` is present | — | `active=true\|false`, `q` (name or employee number) |
| `GET /placements` | same | — | `person_id`, `client_id`, `active` |
| `GET /clients` | same | — | — |

### Backward compatibility of `/people`, `/clients`, `/placements`

Without paging parameters these endpoints keep their v1 behaviour: a **full array**, with nested objects (people include `placements[]` and `has_kiosk_pin`; clients include `client_contacts[]` and `placements[]`; placements include nested `person` and `client`). With a paging parameter they return `{items, next_cursor}` with **flat** rows that also have `external_source`, `external_ref`, `created_at` and `updated_at`. Choose one style per integration.

Other list endpoints (`/leave`, `/expenses`, `/shifts`, `/inbox`, `/conflicts`, `/org/week`) are not paginated. They are bounded by date range or week.

## Example: page through people (captured)

```sh
curl -s "$TT/people?limit=2" -H "Authorization: Bearer $KEY"
```

```json
{
  "data": {
    "items": [
      { "id": "00000000-0000-4000-8000-0000000000d1", "employee_number": "1001", "first_name": "Jan", "last_name": "de Vries", "email": "jan@demo.timetracking.tech", "phone": null, "locale": "nl", "active": true, "user_id": "00000000-0000-4000-8000-0000000000b1", "external_source": null, "external_ref": null, "created_at": "…", "updated_at": "…" },
      { "id": "00000000-0000-4000-8000-0000000000d2", "employee_number": "1002", "first_name": "Fatima", "last_name": "El Amrani", "…": "…" }
    ],
    "next_cursor": "eyJ1IjoiMjAyNi0xMC0yM1QxOTowOTowOS4wOTIzKzAwOjAwIiwiaSI6IjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDBkMiJ9"
  }
}
```

```sh
curl -s "$TT/people?limit=2&cursor=eyJ1IjoiMjAyNi0xMC0yM1QxOTowOTowOS4wOTIzKzAwOjAwIiwiaSI6IjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDBkMiJ9" \
  -H "Authorization: Bearer $KEY"
```

## Fetch all pages

**TypeScript**

```ts
async function* pages<T>(path: string, params: Record<string, string>) {
  let cursor: string | null = null;
  do {
    const qs = new URLSearchParams({ ...params, limit: "500", ...(cursor ? { cursor } : {}) });
    const res = await fetch(`${TT}${path}?${qs}`, { headers: { authorization: `Bearer ${KEY}` } });
    if (res.status === 429) { await new Promise((r) => setTimeout(r, 60_000)); continue; }
    const { data } = await res.json();
    yield* data.items as T[];
    cursor = data.next_cursor;
  } while (cursor);
}

for await (const day of pages("/days", { from: "2026-11-01", to: "2026-11-30", status: "approved,exported,locked" })) {
  console.log(day.work_date, day.person_id, day.minutes);
}
```

**Python**

```python
def pages(path, **params):
    cursor = None
    while True:
        q = {**params, "limit": 500, **({"cursor": cursor} if cursor else {})}
        data = tt("GET", path + "?" + urllib.parse.urlencode(q))
        yield from data["items"]
        cursor = data["next_cursor"]
        if not cursor:
            break

for day in pages("/days", **{"from": "2026-11-01", "to": "2026-11-30", "status": "approved"}):
    print(day["work_date"], day["minutes"])
```

## Incremental sync with `updated_since`

1. Store a watermark: the time your previous sync **started**, not when it finished.
2. Call `GET /days?from=<period start>&to=<period end>&updated_since=<watermark>&limit=500` and follow `next_cursor` to the end.
3. Upsert by `id` on your side. A day row changes when its value, status or approvals change. Its `version` increases.
4. Set the new watermark to the start time of this run. Overlap a few seconds to be safe; upserting by `id` makes the overlap harmless.

`updated_since` compares with `>=`, so the boundary row comes back again. That is intended.

Things a pure `updated_since` sync can miss:

- Deactivated people and placements still appear (with `active: false`).
- Clients deleted with `DELETE /clients/{id}` disappear. Reconcile clients with a full list occasionally.
- Claims are append-only, so `updated_since` on `/claims` uses `created_at`.
