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.

GoalEndpointMethod
Read a variable's raw data points/variables/{variable_id}/valuesGET
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/seriesPOST

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.

ParameterTypeDescription
page / page_sizeintegerPagination controls. page is ignored when format=csv — every page number returns the same first page; page_size is honored
start / endintegerTime range, POSIX milliseconds (inclusive)
formatstringjson (default) or csv
tokenstringAuth token, as an alternative to the header
❗️

An unqualified CSV pull is an unbounded download

?format=csv with no page_size and no time range returns every retained data point — for a reefer probe reporting every few seconds that is tens of thousands of rows. And because page does nothing in CSV mode, pages 2..N of an export are unreachable: narrow the export with page_size or start/end instead 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 tz you pass.

  • Third column — the values, named after the variable's label rather than value. A temperature probe yields a temperature column, a tracker's position variable a position column, so the header changes from variable to variable.

  • Context — the context object, emitted as a Python bytes literal (b'{…}') rather than JSON. Strip the leading b' 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 supports std (standard deviation).
  • POST /data/stats/aggregation/ additionally supports average (an alias for mean, same result) and raw (the most recent data point, returned unaggregated).
❗️

There is no variance and no stddev

Standard 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, stddev and 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.
❗️

Omitting join_dataframes gives you true

The 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 order

With 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:

ProvidedBehavior
NeitherUses the last 100 values from each variable
start onlyFrom start to the most recent data point
end onlyFrom the earliest data point to end
BothWithin 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. start and end are 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 without format=csv to see the reason.

Notes

  • All timestamps are in milliseconds (POSIX). See Data Structure Overview.
  • Time ranges are inclusive on both start and end.
  • Without precision, numeric results use full decimal precision.
  • tz accepts 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.

Did this page help you?