Working with Device Data
Query variable values, aggregated statistics, and raw time-series across your devices.
Working with device data
All of your time-series data lives in variables (see Data Structure Overview). The Device Data API (base URL https://api.surfact.com/api/v1.6/) lets you read those values in three ways. Full request/response details for each endpoint are in the API Reference; this guide covers the concepts and behavior that tie them together.
| Goal | Endpoint | Method |
|---|---|---|
| Read a variable's raw data points | /variables/{variable_id}/values | GET |
| Get a single aggregated statistic for one variable | /variables/{variable_id}/statistics/{aggregation}/{start}/{end} | GET |
| Aggregate across one or more variables | /data/stats/aggregation/ | POST |
| Pull raw series across one or more variables | /data/raw/series | POST |
Variable data
GET /variables/{variable_id}/values returns a paginated list of data points. A JSON response returns the last 100 points by default; a format=csv request returns the entire retained series unless you constrain it. Use query parameters to filter.
| Parameter | Type | Description |
|---|---|---|
page / page_size | integer | Pagination controls. page is ignored when format=csv — every page number returns the same first page; page_size is honored |
start / end | integer | Time range, POSIX milliseconds (inclusive) |
format | string | json (default) or csv |
token | string | Auth token, as an alternative to the header |
An unqualified CSV pull is an unbounded download
?format=csvwith nopage_sizeand no time range returns every retained data point — for a reefer probe reporting every few seconds that is tens of thousands of rows. And becausepagedoes nothing in CSV mode, pages 2..N of an export are unreachable: narrow the export withpage_sizeorstart/endinstead of paging it.
# Last data point
curl -X GET 'https://api.surfact.com/api/v1.6/variables/<variable_id>/values/?page_size=1' \
-H 'X-Auth-Token: your_token_here'
# A specific time range, as CSV (both bounds in POSIX milliseconds)
curl -X GET 'https://api.surfact.com/api/v1.6/variables/<variable_id>/values/?start=<start_ms>&end=<end_ms>&format=csv' \
-H 'X-Auth-Token: your_token_here'{
"count": true,
"next": "https://api.surfact.com/api/v1.6/variables/<variable_id>/values/?page_size=1&page=2",
"previous": null,
"results": [
{ "timestamp": 1635264014782, "value": 0, "context": {}, "created_at": 1635264014782 }
]
}CSV output
format=csv does not hand back the JSON field names. It returns a four-column sheet as text/csv, with Content-Disposition: attachment; filename=values_<variable_id>.csv:
Timestamp,Human readable date (UTC),temperature,Context
1788377538000,2026-09-02 19:32:18,18.5,b'{}'
1788377515000,2026-09-02 19:31:55,18.5,b'{}'
1788377090000,2026-09-02 19:24:50,18.4,b'{}'
-
Timestamp — POSIX milliseconds, same as JSON.
-
Human readable date (UTC) — a derived column with no JSON equivalent. Always UTC, whatever
tzyou pass. -
Third column — the values, named after the variable's label rather than
value. A temperature probe yields atemperaturecolumn, a tracker's position variable apositioncolumn, so the header changes from variable to variable. -
Context — the context object, emitted as a Python bytes literal (
b'{…}') rather than JSON. Strip the leadingb'and the trailing'before parsing. On a tracker this is where the coordinates hide:1788442230000,2026-09-03 13:30:30,257.0,"b'{""lng"":10.79372007,""lat"":59.93113083}'"
created_at is not present in the CSV at all — pull JSON if you need it.
Aggregated statistics
Aggregations summarize a variable's values over a time range. The two endpoints do not share a function set. These work on both:
mean · min · max · sum · count · first · last
GET /variables/{variable_id}/statistics/{aggregation}/…additionally supportsstd(standard deviation).POST /data/stats/aggregation/additionally supportsaverage(an alias formean, same result) andraw(the most recent data point, returned unaggregated).
There is novarianceand nostddevStandard deviation is spelled
std, and only the GET endpoint implements it.POST /data/stats/aggregation/rejects both names with HTTP 400, while the GET endpoint answers HTTP 200 with an empty body{}for any function it does not implement —variance,stddevand outright typos alike. Check that the response actually contains your function's key; a 200 is not proof the aggregation ran.
For a single variable, use the path-based endpoint:
curl -X GET 'https://api.surfact.com/api/v1.6/variables/<variable_id>/statistics/mean/<start_ms>/<end_ms>' \
-H 'X-Auth-Token: your_token_here'To aggregate across multiple variables, POST to /data/stats/aggregation/:
curl -X POST 'https://api.surfact.com/api/v1.6/data/stats/aggregation/' \
-H 'Content-Type: application/json' \
-H 'X-Auth-Token: your_token_here' \
-d '{
"variables": ["<temperature_variable_id>", "<humidity_variable_id>"],
"aggregation": "mean",
"join_dataframes": true,
"start": 1788291200000,
"end": 1788377600000,
"tz": "Europe/Oslo",
"precision": 2
}'join_dataframes: true(default) → a single joined frame combining all requested variables.join_dataframes: false→ one separate result set per variable.
Omittingjoin_dataframesgives youtrueThe flag defaults to true, so leaving it out returns the joined frame — a different number of rows and a different response shape from
false. Set it explicitly if you want per-variable results.
This endpoint also accepts limit and the token query parameter, even though they are documented on the values and raw-series endpoints. limit truncates the input series before aggregating, so it changes the answer rather than just trimming the output: {"aggregation": "count", "limit": 5} returns 5, while the same request without limit returns 100.
Raw series
POST /data/raw/series returns unaggregated data across one or more variables, and lets you choose exactly which columns come back.
Available columns: value.value, timestamp, variable.id, variable.label, variable.name, variable.properties, value.context, device.id, device.name, device.label.
variable.properties and value.context come back as JSON objects rather than scalars. The variable's display colour is the _color key inside variable.properties; there is no column of its own for it, and asking for variable.properties.color fails with HTTP 400.
curl -X POST 'https://api.surfact.com/api/v1.6/data/raw/series' \
-H 'Content-Type: application/json' \
-H 'X-Auth-Token: your_token_here' \
-d '{
"variables": ["<temperature_variable_id>", "<humidity_variable_id>"],
"columns": ["value.value", "timestamp"],
"join_dataframes": false,
"limit": 3,
"tz": "Europe/Oslo",
"precision": 2
}'Read cell positions off the returned columns, never the ones you requested — the API reorders and renames them. timestamp is hoisted to position 0 and returned unprefixed; every other column comes back as <variable_id>.<column>, with each variable's block of columns in the order you listed the variables.
With the default join_dataframes: true, results is a flat array of rows and columns a flat array of strings:
// Requested columns: ["value.value", "timestamp"]
// Returned columns: ["timestamp", "<variable_id>.value.value"]
[1788377538000, 18.5] // [timestamp, value] — the reverse of the requested orderWith join_dataframes: false you get one frame per variable, and columns becomes an array of arrays — one column list per frame, in request order:
// results: [ [ [18.5, 1788377538000] ] ]
// columns: [ ["<variable_id>.value.value", "timestamp"] ]Joined rows are outer-joined on timestamp, so a variable with no sample at a given timestamp contributes null in each of its cells:
// Two variables, columns ["value.value", "value.context"]
[14.7, {}, 257.0, {"lng": 10.79372007, "lat": 59.93113083}]
[14.7, {}, null, null]Time-range behavior
The start/end parameters behave consistently across the aggregation and raw-series endpoints:
| Provided | Behavior |
|---|---|
| Neither | Uses the last 100 values from each variable |
start only | From start to the most recent data point |
end only | From the earliest data point to end |
| Both | Within the range, inclusive on both ends |
The implicit 100-point cap applies only when neither start nor end is supplied. Give either bound on its own and the whole matching series comes back — an end alone returned 1970 rows for a single reefer probe — so pair a one-sided range with limit.
On /data/raw/series, limit is per variable, not per response: two variables with limit: 3 return three rows each. limit: 0 means no limit and returns the entire retained series; 0 is the smallest accepted value, and -1 is rejected with HTTP 400.
Data retention
Every endpoint on this page is bounded by your account's data retention window — 24 months by default. A request reaching further back is rejected outright rather than clipped: you get none of the data, not even the part inside the window.
{
"code": 403003,
"message": "The time range you're trying to retrieve is older than the retention limit in your account (24 months). Please contact your administrator to increase your data retention period."
}Clamp start yourself before querying a long Nordic route archive. Two easy ways to trip this by accident:
- Seconds instead of milliseconds.
startandendare POSIX milliseconds; a seconds-precision value lands in 1970 and comes back 403. format=csv. In CSV mode the 403 body is completely empty — no error JSON, not even a header row — so a rejected export looks like a zero-byte file with no explanation. Re-run it withoutformat=csvto see the reason.
Notes
- All timestamps are in milliseconds (POSIX). See Data Structure Overview.
- Time ranges are inclusive on both
startandend. - Without
precision, numeric results use full decimal precision. tzaccepts IANA timezone names (e.g.Europe/Oslo,Europe/London); it defaults to UTC.- Tokens are valid for 6 hours — generate fresh tokens as needed. See Authentication.
Updated 7 days ago