Pagination
Cursor pagination with limit, cursor and updated_since on TimeTracking list endpoints, which endpoints support it, and how to run incremental syncs.
Last updated: · Markdown version
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:
{ "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)
curl -s "$TT/people?limit=2" -H "Authorization: Bearer $KEY"
{
"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"
}
}
curl -s "$TT/people?limit=2&cursor=eyJ1IjoiMjAyNi0xMC0yM1QxOTowOTowOS4wOTIzKzAwOjAwIiwiaSI6IjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDBkMiJ9" \
-H "Authorization: Bearer $KEY"
Fetch all pages
TypeScript
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
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
- Store a watermark: the time your previous sync started, not when it finished.
- Call
GET /days?from=<period start>&to=<period end>&updated_since=<watermark>&limit=500and follownext_cursorto the end. - Upsert by
idon your side. A day row changes when its value, status or approvals change. Itsversionincreases. - Set the new watermark to the start time of this run. Overlap a few seconds to be safe; upserting by
idmakes 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_sinceon/claimsusescreated_at.