Pagination

Endpoints that return time-series samples — such as Segment samples history — can return more data than fits in a single response. When that happens, the API splits the result into pages and returns a cursor you use to fetch the next one.

Instead of asking for "page 2", you send back the cursor the previous response gave you, and the API continues exactly where it left off.

Parameters

ParameterWhereTypeDescription
pageSizequerynumberUpper bound on the number of samples in each page, from 1 to 5000. Defaults to 1000. A page may contain fewer — read the actual array length rather than assuming pageSize.
cursorquerystringMarks where the next page begins. Copy it from the previous response — don't build it yourself. Omit it on the first request.

A 200 response carries a cursor field when the API may have more data for your query. On the last page the field is left out of the response entirely — treat a missing key as "no more pages" rather than expecting an explicit null.

A cursor is not a guarantee that more data exists — a page can come back with a cursor even when nothing follows it, in which case the next request returns an empty data and no cursor. Keep looping until the cursor is gone, and treat an empty final page as a normal ending.

How to page through a result

  1. Make your first request with the query you want (start, end, resolution, units, optionally pageSize). Leave cursor out.
  2. Check for the cursor field in the response. If it's missing, you're done; if it's a string, request the next page.
  3. Repeat the same request with cursor=<value> from the previous response.
  4. Keep going until a response comes back without a cursor.
📘

Only cursor should change between pages. Keep start, end, resolution, pageSize, and unit parameters identical across the loop. Changing the time range mid-loop is not just inconsistent — a cursor that falls outside the new start/end is rejected with 400 The cursor is invalid on the mold, virus risk, occupancy, ventilation, and hourly radon endpoints, and is silently clamped to the new range on /samples.

If you omit start and end altogether, the query covers the whole measuring segment — from the segment's start to its end, or to now if the segment is still running — which is often more data than one page holds.

Example

# First request — no cursor
curl -X GET \
  "https://ext-api.airthings.com/v1/segments/{segmentId}/samples?start=2024-01-01T00:00:00Z&end=2024-03-01T00:00:00Z&resolution=HOUR" \
  -H "Authorization: Bearer <ACCESS_TOKEN>"

The response carries a cursor, so there may be more data:

{
  "start": "2024-01-01T00:00:00Z",
  "end": "2024-03-01T00:00:00Z",
  "measurementSystem": "METRIC",
  "cursor": "MTcwNzY2MDAwMA==",
  "data": {
    "time": [1704067200, 1704070800, "..."],
    "temp": [21.4, 21.6, "..."],
    "co2":  [612, 640, "..."]
  }
}

Send the same query again with the returned cursor appended, and repeat until a response arrives with no cursor field:

curl -X GET \
  "https://ext-api.airthings.com/v1/segments/{segmentId}/samples?start=2024-01-01T00:00:00Z&end=2024-03-01T00:00:00Z&resolution=HOUR&cursor=MTcwNzY2MDAwMA==" \
  -H "Authorization: Bearer <ACCESS_TOKEN>"

Looping in code

import requests

BASE_URL = "https://ext-api.airthings.com/v1/segments/{segment_id}/samples"

params = {
    "start": "2024-01-01T00:00:00Z",
    "end": "2024-03-01T00:00:00Z",
    "resolution": "HOUR",
}
headers = {"Authorization": f"Bearer {access_token}"}

pages = []
while True:
    resp = requests.get(
        BASE_URL.format(segment_id=segment_id),
        params=params,
        headers=headers,
    )
    resp.raise_for_status()
    body = resp.json()

    pages.append(body["data"])

    cursor = body.get("cursor")
    if not cursor:          # field absent -> last page reached
        break
    params["cursor"] = cursor

The last iteration may append an empty page, since a cursor can be returned even when no data follows it. Filter empty pages out before merging if that matters to you.

Combining pages

Sample data is columnar: data holds parallel arrays (time, temp, co2, …) aligned by index. To merge pages into one continuous series, concatenate each array by key, in the order the pages were returned:

def merge(pages):
    merged = {}
    for page in pages:
        for key, values in page.items():
            merged.setdefault(key, []).extend(values)
    return merged

The keys differ per endpoint: /samples returns time as numbers (Unix seconds), while the mold, virus risk, occupancy, ventilation, and hourly radon endpoints return time as strings holding the same Unix seconds, alongside their own sensor keys. Concatenating by key works either way; normalise the timestamp type yourself if you mix endpoints.

Notes

  • pageSize is an upper bound, not an exact page length. A page can be shorter — on the last page, and depending on the resolution you asked for. Drive your loop off the cursor and the array lengths you actually receive, never off an assumed row count.
  • pageSize and cursor work the same way across all sample-history endpoints (segment and device samples, including the mold, virus risk, occupancy, ventilation, and hourly radon variants): same bounds, same default, same cursor loop. The response body shape differs as described above, and the exact number of samples per page can vary between them.
  • pageSize outside 15000 is rejected with 400.
  • Since pageSize defaults to 1000, a large date range may be paginated even when you don't set it. Always check for a response cursor rather than assuming one request returned everything.

Did this page help you?