Skip to main content
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

GET https://api.zentracloud.io/v5/devices/{device_id}/data

Path parameters

Parameter

Type

Required

Description

device_id

string

Yes

The unique identifier of the device. Example: z6-12345.

Query Parameters

Parameter

Type

Default

Description

direction

string

descending

Order in which data is retrieved and displayed by date. One of ascending or descending. Ignored when since_reading_id is supplied.

start_datetime

string (ISO 8601)

-

Start of the window as an ISO datetime string. A datetime without a timezone is treated as UTC. Example: 2025-10-20T17:15+00:00. Use window or datetimes or timestamps — only one.

end_datetime

string (ISO 8601)

-

End of the window as an ISO datetime string. A datetime without a timezone is treated as UTC. Example: 2025-10-20T17:15+00:00. Use window or datetimes or timestamps — only one.

start_timestamp

integer

-

Start of the window as a Unix timestamp (seconds since 1970-01-01 UTC). Example: 1763445086. Use window or datetimes or timestamps — only one.

end_timestamp

integer

-

End of the window as a Unix timestamp (seconds since 1970-01-01 UTC). Example: 1763445086. Use window or datetimes or timestamps — only one.

window

string

-

A relative time range measured back from the time of the request (server time, UTC). Written as <n><unit>, where <n> is a positive whole number and <unit> is m (minutes), h (hours), d (days), or w (weeks). Example: 24h for the last 24 hours. Must resolve to one year or less — the maximum is 365d or 52w. Use a window or datetimes or timestamps — only one.

since_reading_id

integer

-

Start of the window as a reading ID, exclusive — the response begins at the next ID after this value, so 40 returns a first reading_id of 41. Must be 0 or greater; 0 starts from the earliest reading still retrievable for the device. Results are ordered ascending by reading_id, then by port_num. Takes precedence over direction, start_datetime, end_datetime, start_timestamp, end_timestamp, window, and next_token, which are ignored without error when it is supplied. The exception is aggregate: combining since_reading_id with aggregate=hourly or aggregate=daily returns 422. aggregate=raw, the default, can be passed explicitly alongside it. Pass next_reading_id from the previous response to continue. See Reading IDs and complete pulls below.

units

string

metric

Unit system for returned values, each value's position, and metadata.elevation. One of metric or imperial. Measurements that use the same unit in both systems (kPa, mS/cm, W/m²) are returned unconverted. Each value's unit field reports the unit applied.

aggregate

string

raw

Aggregation level of the returned rows. One of raw, hourly, or daily. raw returns interval readings. hourly and daily return one summary row per series per bin, and each row's stat names the statistic held in value. Works with units, direction, window, and either explicit range pair. Cannot be combined with since_reading_id or latest=true; doing so returns 422. See Aggregated readings below.

next_token

string

-

The token needed to retrieve the next pagination set. See Pagination below. Ignored when since_reading_id is supplied.

Choose one way to specify the time range: the datetime pair (start_datetime / end_datetime), the timestamp pair (start_timestamp / end_timestamp), or a relative window. Combining them returns 422.
Measurements are retained for 914 days. A time range that reaches back further than that is shortened to the retention cutoff rather than rejected. See Data retention in the v5 API Overview.
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.
A 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

device_id

string

The unique identifier (serial) of the device.

device_name

string

The device's display name. Defaults to the device_id when no custom name is set.

location

string

The folder path the device sits in, for example Zone/Farm/Plot etc.

coordinates

string

The device's latitude and longitude, comma-separated.

elevation

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 elevation_unit. null when the location override has no elevation.

elevation_unit

string

The unit elevation is expressed in: m for units=metric, ft for units=imperial. Always present, even when elevation is null.

reading_id_max_available

integer | null

The highest reading_id currently assigned to this device, or null when the device has no readings. Returned on every request, not only when paging by reading ID. Compare it against the last reading_id you received to see how far behind you are.

timezone

string | null

The IANA timezone that hourly and daily bins are cut in, for example America/Los_Angeles: the device's timezone override if one is set, otherwise the organization's timezone. The UTC offset in each row's datetime follows this zone. null on raw responses, including since_reading_id requests.

aggregate

string

The aggregation level applied to this response: raw, hourly, or daily. Returned on every response, including requests that didn't supply aggregate.

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

port_num

integer | null

The sensor port the reading came from. null for logger-level readings such as Signal Strength, which have no physical port; identify those by measurement.

measurement

string

The measured quantity (e.g. Gust Speed).

unit

string

The unit of value, reflecting the requested units (e.g. m/s). Measurements that have no unit, such as NDVI, PRI, counts, ratios, and dielectric permittivity, return an empty string "", never null.

sensor_name

string

The sensor that produced the reading (e.g. ATMOS 41W).

position

number | null

The sensor position this value was measured at, in cm for units=metric or in for units=imperial. Negative is below ground (a depth); positive is above ground (a height). null when the port has no position set.

value

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 hourly and daily rows, the statistic named by stat, rounded to the same precision as that measurement's raw readings. null when there is no value.

timestamp

integer

The reading time as a Unix timestamp (seconds since the epoch, UTC). On hourly and daily rows, the start of the bin the row summarizes.

datetime

string

The reading time as a human-readable datetime with UTC offset. On hourly and daily rows, the start of the bin the row summarizes.

error_code

integer

0 when the sensor is reporting normally. Any other value indicates a problem; look it up in Device & Sensor Error Codes.

reading_id

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. null on computed ET measurements (Hourly and Daily Reference ET and Actual ET), which are computed over a period rather than belonging to a single reading, and on every row of an hourly or daily response.

stats

string

The statistic value holds: sum, mean, max, min, or last. Present only on hourly and daily rows. See Aggregated readings below.

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:

  • sum for accumulating measurements such as precipitation, evapotranspiration, and lightning counts.
  • mean for averaged measurements such as temperature and water content.
  • max for max-type measurements such as solar radiation.
  • min for min-type measurements.
  • last for 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 measurement and by reading_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 as 0.0, which is indistinguishable from a genuine zero.
  • Aggregate rows. hourly and daily rows 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

port_num

integer

The port of the sensor reporting the fault.

sensor_name

string

The sensor reporting the fault, for example TEROS 32.

error_code

integer

A code in the 100–127 range naming the fault. Its description is in error_codes.

timestamp

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.

datetime

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

num_readings

integer

The number of distinct readings with at least one entry in values on this page. When paging with since_reading_id, it can be smaller than the number of reading IDs the page moved past, because some IDs have no data you can see; use next_reading_id, not num_readings, to track your position. In an hourly or daily response, it is the number of bins in the series with the most bins on this page, including bins whose value is null. It is not the number of entries in values. Do not treat it as a page size.

next_token

string | null

The token to pass on the next request to fetch the following page, or null when there are no more pages. See Pagination below. null in a cursor response — reading-ID paging uses next_reading_id instead.

start_datetime

string (ISO 8601) | null

The start of the window covered by this page. null in a cursor response, which is not bounded by a time window.

end_datetime

string (ISO 8601) | null

The end of the window covered by this page. null in a cursor response, which is not bounded by a time window.

next_url

string | null

A ready-to-use URL that fetches the next page. Follow it exactly as given. null on the last page.

next_reading_id

integer | null

The cursor to send as since_reading_id on the next request. null when no readings remain, and null on any request that did not use since_reading_id.

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

403

You lack an Administrator, Editor, or User role in the device's organization.

404

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.

422

Mixing window with datetime or timestamp parameters, mixing datetime and timestamp parameters, a window that resolves to more than one year, a malformed window value, a since_reading_id below 0 or not a whole number, combining aggregate=hourly or aggregate=daily with since_reading_id or latest=true, or an otherwise invalid parameter value.

See HTTP Status Codes for the full model, including 401 and 429.

Notes

  • Carry the device_id from 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_id rather than by date.
  • Aggregate rows have no reading ID. Pull hourly and daily summaries with a time range or window, not since_reading_id.

How did we do?

GET List Devices

Contact