Timeseries
Write points to the series of a timeseries type, read them raw, in buckets, or downsampled, get each series' newest point, list series, and read the ingest log.
Timeseries explains series, fields,
retention, and who may read and write them. Timeseries types are created
and changed with the ontology schema
endpoints and addressed here by their api_name. These endpoints answer
400 for a type that is not a timeseries type, 404 for one you cannot
read, and 403 without view_objects (or, to write, edit_objects).
Series about objects follow their objects’ access; see
How access works. Times
are RFC 3339; responses give them to the microsecond.
Selecting series
Reads select series by any of:
| Field | |
|---|---|
series | a list of series ids |
object | the id of the series’ object |
tags | tag values the series must have, such as {"sensor": "spindle"}; other tags may have any value |
Leaving all of them out selects every series you may read. A read covers at
most 100 series: naming more ids is 400, and so is a selection that
matches more (“name the series, an object, or tags”). An unknown tag is
422 at tags.
Writing
POST /v1/ontology/timeseries/{type}/points
{
"series": [
{ "object": "o4b8j2e6c0t1q5w9r3y7u2i6", "tags": { "sensor": "spindle" },
"points": [
{ "at": "2026-10-26T09:00:00Z", "values": { "temperature": 41.5, "rpm": 1200, "running": true } },
{ "at": "2026-10-26T09:00:10Z", "values": { "temperature": 41.7, "rpm": 1210, "running": true } }
] }
],
"idempotency_key": "saw-2026-10-26T09:00"
}object is required when the type has an object_type and refused when it
has none; tags may name only the type’s tags. values maps fields to
values. idempotency_key is optional, 1-200 characters.
201:
{
"batch_id": "b3a7t9c1h5i2d8x4q6w0e2r4",
"type": "telemetry",
"series": [
{ "id": "s1e3r5i7e9s0i2d4x6c8v0b2", "object": "o4b8j2e6c0t1q5w9r3y7u2i6",
"tags": { "sensor": "spindle" }, "points": 2 }
],
"points": 2,
"inserted": 2,
"replaced": 0,
"unchanged": 0,
"replayed": false
}A point at a time its series already has replaces the stored one
(replaced), or changes nothing if its values are the same (unchanged).
| Status | code | When |
|---|---|---|
201 | written | |
200 | a retry with the same idempotency_key and request: the first result, with replayed: true; nothing is written | |
400 | bad_request | more than 1,000 series or 10,000 points, or a malformed body |
403 | forbidden | no write access to the type |
409 | idempotency_conflict | the key was used for a different request |
422 | validation_failed | one detail per problem, with paths such as series[0].points[3].at or series[0].points[3].values.temperature: a point twice in the batch, a point with no values, a value that fails its field, a time before the retention cutoff or more than a day ahead, an object you cannot see, an unknown tag |
413 | a body over 8 MiB | |
429 | rate_limited | too many batches; see Rate limits |
The whole batch is refused if any point is. With live on, the batch emits
one series.points event per series it changed.
Reading
POST /v1/ontology/timeseries/{type}/query
| Field | |
|---|---|
series, object, tags | the selection |
fields | the fields to return (default all); an unknown one is 400 |
from, to | the time range [from, to); by default from the retention cutoff to a day ahead |
bucket | aggregate per bucket: seconds, or "30s", "5m", "1h", "1d" (1 second to 366 days), aligned to the Unix epoch |
aggregates | with bucket or max_points: any of avg, min, max, sum (numbers), count, first, last (any field) |
max_points | 2-10,000: downsample [from, to) to at most that many buckets per series; needs from and to |
limit | raw reads: 1-10,000, default 1,000 |
cursor | raw reads: next_cursor of the previous page |
group | with bucket or max_points: totals across series |
objects_where, per_series | with group: see totals across series |
bucket and max_points exclude each other. Without either, the read
returns raw points, by series and time:
{
"series": [
{ "id": "s1e3r5i7e9s0i2d4x6c8v0b2", "object": "o4b8j2e6c0t1q5w9r3y7u2i6",
"tags": { "sensor": "spindle" }, "created_at": "2026-10-26T09:00:01.482113Z" }
],
"points": [
{ "series": "s1e3r5i7e9s0i2d4x6c8v0b2", "at": "2026-10-26T09:00:00.000000Z",
"values": { "temperature": 41.5, "rpm": 1200, "running": true } }
],
"next_cursor": null
}With bucket or max_points, it returns buckets, one per series and
bucket that has points:
{
"series": [ { "id": "s1e3r5i7e9s0i2d4x6c8v0b2", "...": "..." } ],
"bucket_seconds": 60,
"buckets": [
{ "series": "s1e3r5i7e9s0i2d4x6c8v0b2",
"start": "2026-10-26T09:00:00.000000Z", "end": "2026-10-26T09:01:00.000000Z", "points": 6,
"values": {
"temperature": { "avg": 42.1, "min": 41.5, "max": 42.9 },
"running": { "last": true } } }
]
}Without aggregates, numbers get avg, min, and max, and other fields
last; with them, each field gets the ones that apply to it (400 if none
applies to any). A read over more than 10,000 buckets in all, counting every
series, is 400: use wider buckets, a shorter range, or fewer series.
Totals across series
With group, the read combines many series into one value per group and
bucket, such as the total power drawn at each site per hour:
{ "fields": ["watts"], "from": "2026-10-26T00:00:00Z", "to": "2026-10-27T00:00:00Z",
"bucket": "1h", "group": { "by": "property", "property": "site" },
"objects_where": { "op": "eq", "property": "kind", "value": "meter" } }It works in two steps. First each series becomes one value per field and
bucket (per_series), then a group’s series are combined (aggregates).
Total power is the sum of each meter’s average in the hour, not the sum of
its raw points, so a meter that reports every second counts as much as one
that reports every minute.
| Field | |
|---|---|
group | {"by": "total"} (one group), {"by": "tags", "tags": [...]} (one or more distinct tags), {"by": "object"}, or {"by": "property", "property": "<name>"}: a top-level scalar property of the series’ objects, such as a string, number, date, or reference |
objects_where | a query filter on the series’ objects: only their series count |
per_series | each series’ value per bucket: avg, min, max, sum (numbers), count, first, last; default avg for numbers, last for other fields |
aggregates | how a group combines its series’ values: sum, avg, min, max (numbers), count; default all five for numbers, count for other fields |
To count points, use "per_series": "count" with "aggregates": ["sum"].
The selection (series, object, tags) still applies, and from, to,
bucket and max_points work as above. Grouping by a property and
objects_where need a type whose series belong to objects (400
otherwise).
{
"bucket_seconds": 3600,
"groups": [
{ "key": { "site": "HQ" }, "series": 412,
"buckets": [
{ "start": "2026-10-26T09:00:00.000000Z", "end": "2026-10-26T10:00:00.000000Z", "series": 400,
"values": { "watts": { "sum": 1234.5, "avg": 3.0, "min": 0.4, "max": 9.8, "count": 400 } } } ] }
],
"truncated": false
}A group’s key holds the tag values, {"object": "<id>"}, the property’s
value, or nothing for total. Its series counts the series in the group,
and a bucket’s series those with a point in that bucket. Groups are
ordered by key.
Totals cover only the series you may read: a series of an object hidden
from you counts nowhere, not even in series. A
restricted property
hidden from you is unknown to group and objects_where (400), as in
queries.
There is no 100-series limit, but a read returns at most 1,000 groups and
10,000 groups × buckets. Past that, whole groups are left out and
truncated is true. Naming more than 100 series by id is still 400.
Totals read every point they cover, so a long range of busy series takes
longer.
POST /v1/ontology/timeseries/{type}/latest
{ "object": "o4b8j2e6c0t1q5w9r3y7u2i6", "fields": ["temperature"] }Takes the selection and fields, nothing else. Each
selected series with its newest point, or null if it has none left:
{
"series": [
{ "id": "s1e3r5i7e9s0i2d4x6c8v0b2", "object": "o4b8j2e6c0t1q5w9r3y7u2i6",
"tags": { "sensor": "spindle" }, "created_at": "2026-10-26T09:00:01.482113Z",
"latest": { "at": "2026-10-26T09:05:50.000000Z", "values": { "temperature": 43.2 } } }
]
}GET /v1/ontology/timeseries/{type}/series
The series you may read, by id, a page at a time.
| Query | |
|---|---|
object | only the series of this object |
tag.<name> | only series with this tag value, such as tag.sensor=spindle |
limit | 1-500, default 100 |
cursor | next_cursor of the previous page |
{ "data": [ { "id": "s1e3r5i7e9s0i2d4x6c8v0b2", "object": "o4b8j2e6c0t1q5w9r3y7u2i6",
"tags": { "sensor": "spindle" }, "created_at": "2026-10-26T09:00:01.482113Z" } ],
"next_cursor": null }The ingest log
GET /v1/ontology/timeseries/{type}/batches
Every batch written to the type, newest first.
| Query | |
|---|---|
before | only batches written before this time: pass the last created_at of the previous page |
limit | 1-500, default 100 |
{
"data": [
{ "id": "b3a7t9c1h5i2d8x4q6w0e2r4",
"written_by": { "id": "a9g3e7n1t5b2o8t4q6w0e2r4", "kind": "agent", "method": "api_token", "via": "rest" },
"series": 1, "points": 2, "inserted": 2, "replaced": 0,
"first_at": "2026-10-26T09:00:00.000000Z", "last_at": "2026-10-26T09:00:10.000000Z",
"created_at": "2026-10-26T09:00:01.482113Z" }
]
}method is session, oauth, external_jwt, or api_token; via is
rest or mcp. Replays are not batches and are not listed.