Skip to content

Request an integration

Tell us which system you want to connect. We review every request weekly and publish a step-by-step guide or build the connection.

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

  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.