Skip to content

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.

  1. First request: call the endpoint with your filter parameters (date range, project, etc.)
  2. Check the response: if there’s more data, the response includes a continuationToken
  3. Next page: pass the token back as the ContinuationToken query parameter (keep all other parameters the same)
  4. Repeat until no continuationToken is returned

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..."
}
}
FieldDescription
dataArray of records for the current page
metaThe filter the server applied to this request, echoed back with every filter field (shortened in the example above)
continuationTokenOpaque token to pass as the ContinuationToken query parameter. Absent/empty when there are no more pages.
syncTypeNamefull or incremental — see Full vs incremental sync
recordCountNumber of records in data on this page, deletion tombstones included
deletedRecordCountHow many of those records are deletion tombstones (isDeleted: true)
links.nextURL for the next page (convenience — you can also build it yourself)

The extraction endpoints share a common filter model:

ParameterTypeDescription
FromDateTimedatetimeFull-sync window start
ToDateTimedatetimeFull-sync window end
ModifiedSincedatetimeIncremental sync: only records modified at/after this instant. Switches the ordering to modification time.
ModifiedBeforedatetimeUpper bound for incremental sync. Requires ModifiedSince and must be after it.
ProjectIdstringRestrict to one project
CompanyIdstringOnly data registered in (owned by) this company
OwnCompanyDataOnlyboolOnly data registered by your own company, not data other companies registered on projects shared with you. Ignored by the checklist endpoints. See Company scope
ExcludeDataFromSubsidiariesboolFor parent companies: leave out data from subsidiary companies. See Company scope
ChunkLimitintTarget page size. Default 2500, clamped to 1–10000.
ContinuationTokenstringThe 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-lines and /v1/payroll-lines-extended: ModifiedSince must be within the last 45 days. An older value is rejected with the message ModifiedBefore must be within 45 days from today, although the check is on ModifiedSince.
  • /v1/machine-registrations and /v1/crew-list-registrations: send FromDateTime and ToDateTime together or not at all, and FromDateTime can’t be after ToDateTime.

A request that breaks one of these rules, or the ModifiedBefore rule above, currently returns 500 with the rule in message; see Error Handling.

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 wantSet
Everything you can see (default)neither flag
Only data registered by your company and its subsidiariesOwnCompanyDataOnly=true
Leave out your subsidiaries’ dataExcludeDataFromSubsidiaries=true
Only data registered by your own company, no subsidiariesboth 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.

ModeTriggerUse for
FullModifiedSince not setInitial backfill of a date window
IncrementalModifiedSince setOngoing 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.

Examples use the environment variables from Authentication and default to the test environment.

Terminal window
# Page 1
curl -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 absent

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"] = continuation

In 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 file
using 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.

  • Don’t parse the token. The ContinuationToken is 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 ModifiedSince tight. For incremental syncs, pass a timestamp close to your last successful sync — a far-back ModifiedSince on a large tenant is the heaviest query pattern.
  • Chunk large backfills. Prefer month-by-month FromDateTime/ToDateTime windows over one unbounded call.