Table of Contents
GET Device Readings
Pull time-series measurements for a single device with GET /v5/devices/{device_id}/data — parameters, response schema, date and reading-ID paging, and code examples.
Table of Contents
GET /v5/devices/{device_id}/data
Retrieves time-series measurements for a single device. Get the device_id from List Devices, then call this endpoint to pull the device's readings over a date range, timestamp range, relative window, or reading ID. Set aggregate to hourly or daily to get hourly or daily summaries for every sensor on the device instead of its interval readings.
Authentication
Authenticated with your API key in the X-API-Key header; the user is resolved from the key. Access requires an Administrator, Editor, or User role in the device's organization. See Authentication and Data access control in the Overview.
Explore the interactive v5 endpoint in the OpenAPI/Swagger reference.
Request
Path parameters
Parameter | Type | Required | Description |
| string | Yes | The unique identifier of the device. Example: |
Query Parameters
Parameter | Type | Default | Description |
| string |
| Order in which data is retrieved and displayed by date. One of |
| string (ISO 8601) | - | Start of the window as an ISO datetime string. A datetime without a timezone is treated as UTC. Example: |
| string (ISO 8601) | - | End of the window as an ISO datetime string. A datetime without a timezone is treated as UTC. Example: |
| integer | - | Start of the window as a Unix timestamp (seconds since 1970-01-01 UTC). Example: |
| integer | - | End of the window as a Unix timestamp (seconds since 1970-01-01 UTC). Example: |
| string | - | A relative time range measured back from the time of the request (server time, UTC). Written as |
| integer | - | Start of the window as a reading ID, exclusive — the response begins at the next ID after this value, so |
| string |
| Unit system for returned values, each value's position, and |
| string |
| Aggregation level of the returned rows. One of |
| string | - | The token needed to retrieve the next pagination set. See Pagination below. Ignored when |
start_datetime / end_datetime), the timestamp pair (start_timestamp / end_timestamp), or a relative window. Combining them returns 422.since_reading_id sits outside this rule. It is not rejected alongside the other time-range parameters; it overrides them silently. Supply it and the response is ordered by reading ID regardless of what else you sent, with direction, both datetime parameters, both timestamp parameters, window, and next_token ignored and no 422 returned. The one exception is aggregate=hourly or aggregate=daily, which cannot be combined with since_reading_id and returns 422.window value must be a positive whole number followed by a single lowercase unit character. Any other form — decimals, negatives, zero, uppercase units, long unit names, whitespace, or a bare number — returns 422.Example requests
cURL
curl --request GET \
--url 'https://api.zentracloud.io/v5/devices/z6-00930/data?start_datetime=2026-05-29T00%3A00%3A00-00%3A00&end_datetime=2026-05-30T23%3A59%3A00-00%3A00&direction=descending&units=metric' \
--header 'Accept: */*' \
--header 'X-API-Key: YOUR_API_KEY'
curl --request GET \
--url 'https://api.zentracloud.io/v5/devices/z6-00930/data?window=24h&direction=descending&units=metric' \
--header 'Accept: */*' \
--header 'X-API-Key: YOUR_API_KEY'
curl --request GET \
--url 'https://api.zentracloud.io/v5/devices/z6-00930/data?window=7d&aggregate=daily&units=metric' \
--header 'Accept: */*' \
--header 'X-API-Key: YOUR_API_KEY'
Paging by reading ID. The first request starts from the earliest retrievable reading.
curl --request GET \
--url 'https://api.zentracloud.io/v5/devices/z6-00930/data?since_reading_id=0&units=metric' \
--header 'Accept: */*' \
--header 'X-API-Key: YOUR_API_KEY'
The response returns a next_reading_id. Send it back as since_reading_id to continue:
curl --request GET \
--url 'https://api.zentracloud.io/v5/devices/z6-00930/data?since_reading_id=27116&units=metric' \
--header 'Accept: */*' \
--header 'X-API-Key: YOUR_API_KEY'
JavaScript — Fetch
const url = 'https://api.zentracloud.io/v5/devices/z6-00930/data?start_datetime=2026-05-29T00%3A00%3A00-00%3A00&end_datetime=2026-05-30T23%3A59%3A00-00%3A00&direction=descending&units=metric';
const options = {
method: 'GET',
headers: { 'X-API-Key': 'YOUR_API_KEY', Accept: '*/*' }
};
try {
const response = await fetch(url, options);
const data = await response.json();
console.log(data);
} catch (error) {
console.error(error);
}
Python — Requests
import requests
url = "https://api.zentracloud.io/v5/devices/z6-00930/data"
querystring = {
"start_datetime": "2026-05-29T00:00:00-00:00",
"end_datetime": "2026-05-30T23:59:00-00:00",
"direction": "descending",
"units": "metric",
}
headers = {
"X-API-Key": "YOUR_API_KEY",
"Accept": "*/*",
}
response = requests.get(url, headers=headers, params=querystring)
print(response.json())
PHP — cURL
<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.zentracloud.io/v5/devices/z6-00930/data?start_datetime=2026-05-29T00%3A00%3A00-00%3A00&end_datetime=2026-05-30T23%3A59%3A00-00%3A00&direction=descending&units=metric",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => "GET",
CURLOPT_HTTPHEADER => [
"Accept: */*",
"X-API-Key: YOUR_API_KEY",
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}
R — httr
library(httr)
url <- "https://api.zentracloud.io/v5/devices/z6-00930/data"
queryString <- list(
start_datetime = "2026-05-29T00:00:00-00:00",
end_datetime = "2026-05-30T23:59:00-00:00",
direction = "descending",
units = "metric"
)
response <- VERB("GET", url, query = queryString,
add_headers('X-API-Key' = 'YOUR_API_KEY'),
accept("*/*"))
content(response, "text")
Response
A 200 OK returns five top-level fields: metadata about the device, the values for the requested window, pagination with a next_token or next_reading_id when more pages are available, sensor_errors listing sensor-status faults currently detected on the device's ports, and error_codes describing every error code that appears in values or sensor_errors.
Example Response
"metadata": {
"device_id": "z6-01455",
"device_name": "Market Logger",
"location": "Market Place",
"coordinates": "46.7525833, -117.1819223",
"elevation": 774.0,
"elevation_unit": "m",
"reading_id_max_available": 89116,
"aggregate": "raw",
"timezone": null
},"pagination": { ...unchanged... },
"sensor_errors": [],
"error_codes": {
"0": "No error"
}metadata
Describes the device the readings belong to.
Field | Type | Description |
| string | The unique identifier (serial) of the device. |
| string | The device's display name. Defaults to the |
| string | The folder path the device sits in, for example |
| string | The device's latitude and longitude, comma-separated. |
| number | null | The device's current elevation, rounded to a whole number: the location override if one is set, otherwise the latest GPS altitude. This is the device's elevation today, not at the time of any measurement. Expressed in |
| string | The unit elevation is expressed in: m for |
| integer | null | The highest |
| string | null | The IANA timezone that hourly and daily bins are cut in, for example |
| string | The aggregation level applied to this response: |
values []
Each item is a single measurement from one sensor port at one point in time, or, in an aggregated response, a summary of one series over one bin. A series is identified by port_num, measurement, sensor_name, and position together. Multi-depth sensors such as TEROS 54 and TEROS 06 report one series per depth on the same port, told apart only by position. In a time-range request, entries are grouped by series and ordered by time within each series, following the direction parameter; in an aggregated response, that is each bin's start (timestamp). The order of the series themselves is not guaranteed.
When since_reading_id is supplied, entries are ordered by reading_id ascending and then by port_num, and computed ET measurements (Hourly and Daily Reference ET and Actual ET) are not returned.
Field | Type | Description |
| integer | null | The sensor port the reading came from. |
| string | The measured quantity (e.g. |
| string | The unit of |
| string | The sensor that produced the reading (e.g. |
| number | null | The sensor position this value was measured at, in cm for |
| number | null | The measured value, rounded to the measurement's display precision in the returned unit — the same precision the ZENTRA Cloud app and in-app download use. Precision depends on the sensor and the unit: each measurement has a sensor-defined number of decimal places, which some unit conversions adjust (for example, air temperature is reported to 0.1 degree in both °C and °F). On |
| integer | The reading time as a Unix timestamp (seconds since the epoch, UTC). On |
| string | The reading time as a human-readable datetime with UTC offset. On |
| integer | |
| integer | null | The identifier of the reading this entry came from. Every entry produced by the same reading carries the same value — one reading commonly produces dozens of entries. |
| string | The statistic |
Aggregated readings
aggregate=hourly and aggregate=daily return one summary row per series per bin in place of interval readings. Each row's stat names the statistic in value:
sumfor accumulating measurements such as precipitation, evapotranspiration, and lightning counts.meanfor averaged measurements such as temperature and water content.maxfor max-type measurements such as solar radiation.minfor min-type measurements.lastfor everything else.
A row's timestamp and datetime mark the start of its bin, and a range selects the bins whose start falls within it. A bin that started before your range begins is not returned, even if it overlaps the range. For example, window=7d resolved at 14:00 leaves out the bin for the day the window starts in. To get whole local days, use an explicit range that starts at local midnight and includes the device's UTC offset. A datetime without a timezone is treated as UTC.
Bins follow local clock boundaries in the device's timezone (its override if one is set, otherwise the organization's), which is reported in metadata.timezone. A daily bin is one local calendar day, so it spans 23 or 25 hours on a DST transition day. The most recent bin may still be accumulating, and its value can change until the bin closes.
If the device's measurement interval is equal to or longer than the bin size, no aggregates exist at that level and values is empty. The API does not fall back to interval readings. A device that measures hourly returns nothing for aggregate=hourly.
Aggregate rows have reading_id: null and honor units. Aggregate requests page by calendar month like raw requests, and they are clamped to the same retention cutoff. They cannot be combined with since_reading_id or latest=true (422). To pull aggregates, use a time range or window rather than a reading ID.
Each aggregate row reports its statistic over the valid samples in the bin and carries the error_code of the bin's last sample. A row can therefore carry a value alongside a non-zero error_code. The reverse also holds: error_code: 0 on an aggregate row means the last sample was valid, not that every sample in the bin was.
If every sample in a bin was in error, the row is still returned with the last sample's non-zero error_code, and its value can be null or 0.0. sum rows (precipitation, evapotranspiration, lightning counts) return 0.0, so check error_code before treating a 0.0 as a measured zero. A bin with no samples at all returns no row.
Error codes and missing values.
error_code is the authoritative signal that something was wrong with a measurement. A non-zero error_code is a sensor-level condition, not a transport error. Do not infer it from value, in either direction.
A row with a non-zero error_code may still carry a number. Individual sensor readings that error usually come back with value: null, but that is a convention of how readings are written rather than something the response guarantees. Two kinds of row depart from it by design:
- Computed ET measurements. A date-range request also returns Hourly and Daily Reference ET and Actual ET, identifiable by the Hourly or Daily prefix on
measurementand byreading_id: null. These carry the error code from the last reading in the period alongside a value computed from the readings that were valid. If every reading in the period errored, the value can come back as0.0, which is indistinguishable from a genuine zero. - Aggregate rows.
hourlyanddailyrows report statistics over the valid samples in the bin and carry the last sample's code. See Aggregated readings above.
Equally, value: null is not by itself an error signal. Read a non-zero error_code as "there was a problem with this measurement." On a computed ET measurement or an aggregate row, read it as "the most recent reading in this period had a problem."
Slow-moving sensor-status faults, such as Sensor Offline, Refill Required, and Auto-pump Low Battery (codes 100–127), are decoded from sensor metadata rather than from a reading. They never appear on a value. They are reported in sensor_errors instead.
sensor_errors
Sensor-status faults currently detected on the device's ports, such as Sensor Offline, Refill Required, and Auto-pump Low Battery (codes 100–127). These are decoded from sensor metadata rather than from a reading, so they never appear as an error_code in values.
The list reflects the device's current status, not the requested time range. A request for last year's data returns the faults detected today. sensor_errors is present on every response; it is an empty array when no faults are detected. Each fault on a port is its own entry, and entries are sorted by port_num, then by error_code. Informational states such as Auto-pump In-Line are not listed, and neither are ports with no sensor configured or whose sensor has since changed.
Field | Type | Description |
| integer | The port of the sensor reporting the fault. |
| string | The sensor reporting the fault, for example |
| integer | A code in the |
| integer | When ZENTRA Cloud last detected this sensor's status, in Unix seconds. For most sensors this is when the device last reported the sensor's status; for ATMOS 41 Gen 2, ATMOS 41W, ATMOS 51, and ATMOS 31, it is the latest reading that refreshed it. It is not the time a value was measured, and not necessarily when the fault began. |
| string | The same instant as a human-readable datetime, with the UTC offset of the device's timezone (its override if one is set, otherwise the organization's). |
error_codes
A lookup of the error codes present in this response. Each key is an error_code (as a string) that appears in values or sensor_errors, and each value is its catalog description. 0 is always "No error". See Device & Sensor Error Codes for the full list.
"error_codes": {
"0": "No error",
"137": "Invalid Value"
}pagination
Field | Type | Description |
| integer | The number of distinct readings with at least one entry in |
| string | null | The token to pass on the next request to fetch the following page, or |
| string (ISO 8601) | null | The start of the window covered by this page. |
| string (ISO 8601) | null | The end of the window covered by this page. |
| string | null | A ready-to-use URL that fetches the next page. Follow it exactly as given. |
| integer | null | The cursor to send as |
Example response
{
"metadata": {
"device_id": "z6-01455",
"device_name": "Market Logger",
"location": "Market Place",
"coordinates": "46.7525833, -117.1819223",
"elevation": 774.0,
"elevation_unit": "m",
"reading_id_max_available": 89116
},
"values": [
{
"port_num": 6,
"measurement": "Water Content",
"unit": "m³/m³",
"sensor_name": "TEROS 54",
"position": -15.0,
"value": 0.338,
"timestamp": 1788393600,
"datetime": "2026-09-02T17:00:00-07:00",
"error_code": 0,
"reading_id": 89024
},
{
"port_num": 6,
"measurement": "Water Content",
"unit": "m³/m³",
"sensor_name": "TEROS 54",
"position": -15.0,
"value": 0.338,
"timestamp": 1788394500,
"datetime": "2026-09-02T17:15:00-07:00",
"error_code": 0,
"reading_id": 89025
},
// ... the remaining readings in this series, then the other measurement series
{
"port_num": 2,
"measurement": "Hourly Reference ET",
"unit": "mm",
"sensor_name": "ATMOS 41 G2",
"position": null,
"value": 0.11,
"timestamp": 1788393600,
"datetime": "2026-09-02T17:00:00-07:00",
"error_code": 0,
"reading_id": null
},
{
"port_num": null,
"measurement": "Signal",
"unit": "%",
"sensor_name": "Signal Strength",
"position": null,
"value": 84.0,
"timestamp": 1788397200,
"datetime": "2026-09-02T18:00:00-07:00",
"error_code": 0,
"reading_id": 89028
}
],
"pagination": {
"num_readings": 5,
"next_token": null,
"next_url": null,
"next_reading_id": null,
"start_datetime": "2026-09-03T00:00:00Z",
"end_datetime": "2026-09-03T01:00:00Z"
},
"error_codes": {
"0": "No error"
}
}In a time-range response num_readings counts readings, not entries in values[]. The response above reports 5 readings and contains 294 entries, because this device reports 58 measurements per reading.
reading_id_max_available is 89116 while the last reading returned is 89028, so this device holds a further 88 readings outside the requested hour.
Pagination
The simplest way to page is to follow next_url. It already carries the correct path, your choices such as units and direction, and the new token — so you do not have to rebuild the URL yourself. Keep following it until it comes back null.
next_url deliberately leaves out the time-window parameters (start_datetime, end_datetime, start_timestamp, end_timestamp, and window). The token already encodes the remaining window and the sort direction, so carrying the original time parameters forward would conflict with it. Because window resolves against the server clock at request time, this also means a paged window is fixed at the first request and does not shift as you follow next_url.
An empty page does not mean you are finished. A response can carry values: [] and num_readings: 0 and still have more pages behind it — the window it covered simply held no data. Only a null cursor ends the walk: next_token: null when paging by token, next_reading_id: null when paging by reading ID.
If you would rather build requests yourself, pass the returned next_token back as the next_token parameter.
When you page with since_reading_id, next_url carries since_reading_id in place of next_token, so following it works exactly the same way. To build the request yourself instead, take next_reading_id from the response and send it as since_reading_id. Paging is finished when next_reading_id comes back null.
When requesting data in descending order (newest first), the first page contains only the portion of the current month up to your requested end_datetime or end_timestamp. As a result, this initial page is often smaller than subsequent pages, which each contain a full calendar month of data. A request that starts mid-month ends its first page at the month boundary, not 30 days later.
A cursor page covers 30 days of the device's timeline from the first reading after your cursor, however many readings that turns out to be.
Reading-ID pages are not small. A device reporting every 15 minutes across a dozen sensors produces well over 100,000 entries in a single 30-day page — tens of megabytes, and several seconds to return. Stream or page incrementally rather than holding a full history pull in memory.
Reading IDs and complete pulls
Every reading a device uploads is assigned a reading_id. The sequence belongs to the device, increases by one per reading, and never skips a value. IDs are assigned in the order readings arrive at ZENTRA Cloud, which is not necessarily the order they were measured — a logger uploading backfilled data receives new IDs for older measurements.
One reading normally produces several entries in values[], one per sensor measurement, and every entry carries that reading's reading_id. Repeated IDs down the array are expected. In a cursor response these entries are ordered by reading_id, then by port_num.
To take a complete copy of a device's history, page on since_reading_id rather than on dates. Start at 0, take next_reading_id from each response, and pass it as since_reading_id on the next request, until next_reading_id comes back null. metadata.reading_id_max_available is the highest ID the device currently holds, so you can compare it against the last ID you received at any point to see how far behind you are.
Because IDs follow arrival rather than measurement time, this is also the reliable way to poll for new data. A date-based query for "everything since yesterday" misses readings that arrived today carrying older timestamps. A cursor picks them up, because they were assigned new IDs when they arrived.
since_reading_id is exclusive and applies to the whole reading: since_reading_id=27115 returns nothing from reading 27115 and begins at 27116.
A missing ID in values[] does not mean missing data. A reading whose measurements cannot be resolved against your organization's sensor configuration is still counted by the cursor but produces no entries, so its ID will not appear in the output. The cursor guarantees you were offered every reading — not that every ID is represented in what you received.
The sequence does not necessarily reach back to a device's first-ever measurement. A device that predates the v5 data store has readings older than its lowest retrievable reading_id, and those are not available through this endpoint.
Rate limiting
Rate-limited per device, not per user. The budget belongs to the device and is shared by every client reading it, so another integration polling the same device can exhaust it. A 429 includes a Retry-After header with the number of seconds to wait. See Rate Limiting.
HTTP Status Codes
Status | Endpoint-specific cause |
| You lack an Administrator, Editor, or User role in the device's organization. |
| The device does not exist, or you have workspace-only access and the device is not shared into any of your workspaces. A 404 does not always mean the serial number is wrong. |
| Mixing |
See HTTP Status Codes for the full model, including 401 and 429.
Notes
- Carry the
device_idfrom List Devices into the path. - Timestamps are seconds since the Unix epoch (UTC).
- To take a complete copy of a device's history, or to catch readings that arrived after their measurement time, page with
since_reading_idrather than by date. - Aggregate rows have no reading ID. Pull
hourlyanddailysummaries with a time range orwindow, notsince_reading_id.
How did we do?
GET List Devices