Pagination
The Data Extraction API uses cursor-based pagination with
ContinuationToken. This approach is reliable for large datasets — you won’t
miss or duplicate records even if data changes between pages.
How it works
Section titled “How it works”- First request: call the endpoint with your filter parameters (date range, project, etc.)
- Check the response: if there’s more data, the response includes a
continuationToken - Next page: pass the token back as the
ContinuationTokenquery parameter (keep all other parameters the same) - Repeat until no
continuationTokenis returned
Response structure
Section titled “Response structure”Every Data Extraction endpoint returns this structure, except
/v1/item-registrations/web-query. That one returns the whole result as
semicolon-separated text for Power BI, without paging, and takes its own
filters; see Power Query Examples.
{ "data": [], "meta": { "fromDateTime": "2026-01-01T00:00:00", "toDateTime": "2026-12-31T00:00:00", "chunkLimit": 2500 }, "continuationToken": "eyJ...", "syncTypeName": "full", "recordCount": 2500, "deletedRecordCount": 0, "links": { "next": "/v1/time-registrations?FromDateTime=...&ContinuationToken=eyJ..." }}| Field | Description |
|---|---|
data | Array of records for the current page |
meta | The filter the server applied to this request, echoed back with every filter field (shortened in the example above) |
continuationToken | Opaque token to pass as the ContinuationToken query parameter. Absent/empty when there are no more pages. |
syncTypeName | full or incremental — see Full vs incremental sync |
recordCount | Number of records in data on this page, deletion tombstones included |
deletedRecordCount | How many of those records are deletion tombstones (isDeleted: true) |
links.next | URL for the next page (convenience — you can also build it yourself) |
Shared filter parameters
Section titled “Shared filter parameters”The extraction endpoints share a common filter model:
| Parameter | Type | Description |
|---|---|---|
FromDateTime | datetime | Full-sync window start |
ToDateTime | datetime | Full-sync window end |
ModifiedSince | datetime | Incremental sync: only records modified at/after this instant. Switches the ordering to modification time. |
ModifiedBefore | datetime | Upper bound for incremental sync. Requires ModifiedSince and must be after it. |
ProjectId | string | Restrict to one project |
CompanyId | string | Only data registered in (owned by) this company |
OwnCompanyDataOnly | bool | Only data registered by your own company, not data other companies registered on projects shared with you. Ignored by the checklist endpoints. See Company scope |
ExcludeDataFromSubsidiaries | bool | For parent companies: leave out data from subsidiary companies. See Company scope |
ChunkLimit | int | Target page size. Default 2500, clamped to 1–10000. |
ContinuationToken | string | The paging cursor |
The metadata endpoints /v1/project, /v1/project/work-breakdown-structure,
/v1/resource and /v1/user support the same model minus the
FromDateTime/ToDateTime window.
Some endpoints add their own rules:
/v1/payroll-linesand/v1/payroll-lines-extended:ModifiedSincemust be within the last 45 days. An older value is rejected with the messageModifiedBefore must be within 45 days from today, although the check is onModifiedSince./v1/machine-registrationsand/v1/crew-list-registrations: sendFromDateTimeandToDateTimetogether or not at all, andFromDateTimecan’t be afterToDateTime.
A request that breaks one of these rules, or the ModifiedBefore rule
above, currently returns 500 with the rule in message; see
Error Handling.
Company scope
Section titled “Company scope”By default an extraction returns the data your company can see: data registered by your company and its subsidiaries, and data other companies registered on projects shared with yours (for example a subcontractor’s hours on your project). Two flags narrow this, and you can combine them:
| You want | Set |
|---|---|
| Everything you can see (default) | neither flag |
| Only data registered by your company and its subsidiaries | OwnCompanyDataOnly=true |
| Leave out your subsidiaries’ data | ExcludeDataFromSubsidiaries=true |
| Only data registered by your own company, no subsidiaries | both flags |
On /v1/checklist-registrations and /v1/machine-checklist-registrations,
OwnCompanyDataOnly is ignored, CompanyId filters on the project’s company,
and subsidiaries’ checklists are returned only to an administrator client on
the parent company. See Checklists from
subsidiaries.
Full vs incremental sync
Section titled “Full vs incremental sync”| Mode | Trigger | Use for |
|---|---|---|
| Full | ModifiedSince not set | Initial backfill of a date window |
| Incremental | ModifiedSince set | Ongoing sync — catches new and edited records. Records that carry an isDeleted field can also come back as deletion tombstones (isDeleted: true) in this mode |
The recommended pattern: one full sync to backfill, then scheduled
incremental syncs passing ModifiedSince = <last successful sync timestamp>.
/v1/images records have no isDeleted field, so an incremental sync of
images doesn’t tell you about deleted images.
Example: paginating through all records
Section titled “Example: paginating through all records”Examples use the environment variables from Authentication and default to the test environment.
# Page 1curl -H "Authorization: Bearer $TOKEN" \ "$DITIO_REPORTING_BASE/v1/time-registrations?FromDateTime=2026-01-01&ToDateTime=2026-12-31"
# Page 2 (using the continuation token from page 1)curl -H "Authorization: Bearer $TOKEN" \ "$DITIO_REPORTING_BASE/v1/time-registrations?FromDateTime=2026-01-01&ToDateTime=2026-12-31&ContinuationToken=eyJwYWdl..."
# Continue until continuationToken is absentFetching all pages in code
Section titled “Fetching all pages in code”The guides call one of these helpers whenever they read a paginated
endpoint. Each follows continuationToken until it is absent, keeps every
other query parameter unchanged, and hands back the records one by one. If
the access token expires mid-run (401), they fetch a fresh token once and
retry the same page — so paste the token helper
too. The refresh uses the same credentials as the initial token: by default
DITIO_CLIENT_ID / DITIO_CLIENT_SECRET, so if you hold one client per
customer, pass that customer’s client_id and client_secret to the helper
as well.
import requests
def fetch_all(url, headers, params=None, scope="reportingapiv1", client_id=None, client_secret=None): """Yield every record from a paginated Data Extraction endpoint.""" params = dict(params or {}) while True: response = requests.get(url, headers=headers, params=params, timeout=30) if response.status_code == 401: # Token expired mid-run: refresh once (same credentials) and retry the same page. token = get_token(scope, client_id, client_secret) headers = {**headers, "Authorization": f"Bearer {token}"} response = requests.get(url, headers=headers, params=params, timeout=30) response.raise_for_status() page = response.json() yield from page["data"] continuation = page.get("continuationToken") if not continuation: return params["ContinuationToken"] = continuationIn C#, paste the DitioPaging class at the end of Program.cs (after your
top-level statements) or into its own file, with the using lines at the top
of that file, and call DitioPaging.FetchAllAsync<T>(http, url) (add
clientId: ..., clientSecret: ... if you hold one client per customer).
Use one HttpClient per API client (per customer): don’t share an HttpClient across customers or run FetchAllAsync calls for different clients on the same instance, because the token refresh updates its default Authorization header.
// at the top of the fileusing System.Net;using System.Net.Http.Headers;using System.Net.Http.Json;
static class DitioPaging{ // `http` must already carry the bearer token (see Authentication). // Pass the full URL including your filters, e.g. "...?FromDateTime=2026-01-01". public static async IAsyncEnumerable<T> FetchAllAsync<T>( HttpClient http, string url, string scope = "reportingapiv1", string? clientId = null, string? clientSecret = null) { var separator = url.Contains('?') ? "&" : "?"; string? continuationToken = null; do { var pageUrl = continuationToken is null ? url : $"{url}{separator}ContinuationToken={Uri.EscapeDataString(continuationToken)}";
var response = await http.GetAsync(pageUrl); if (response.StatusCode == HttpStatusCode.Unauthorized) { // Token expired mid-run: refresh once (same credentials) and retry the same page. response.Dispose(); var token = await DitioAuth.GetTokenAsync( http, scope, clientId, clientSecret, forceRefresh: true); http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); response = await http.GetAsync(pageUrl); } response.EnsureSuccessStatusCode();
// ReadFromJsonAsync matches camelCase JSON to PascalCase properties. var page = await response.Content.ReadFromJsonAsync<Page<T>>(); foreach (var record in page!.Data) yield return record;
continuationToken = page.ContinuationToken; } while (!string.IsNullOrEmpty(continuationToken)); }
private record Page<T>(List<T> Data, string? ContinuationToken);}Use DitioPaging.FetchAllAsync<JsonElement> (with using System.Text.Json;)
if you don’t want to declare a record type.
Best practices
Section titled “Best practices”- Don’t parse the token. The
ContinuationTokenis an opaque string; its format may change. - Don’t modify filter parameters between pages. Once you start paginating, keep the filter identical and only add the token — changing filters restarts the stream.
- Handle token expiry. If your access token expires mid-pagination,
refresh it (Token Expiry)
and continue with the same
ContinuationToken. - Keep
ModifiedSincetight. For incremental syncs, pass a timestamp close to your last successful sync — a far-backModifiedSinceon a large tenant is the heaviest query pattern. - Chunk large backfills. Prefer month-by-month
FromDateTime/ToDateTimewindows over one unbounded call.
Related
Section titled “Related”- Quickstart — pagination in a working example
- Data Extraction reference — every extraction endpoint, its filters, and “Try it”