Many series at once
Write, read, catalogue, and summarise many series on many records in one call each.
A fleet dashboard, a map, a scheduled rebuild, a monitoring job: these consumers touch many records at a time, and one request per record does not scale. Five POST routes under /timeseries answer everything such a consumer needs, each in one or two database queries however many series are involved.
They all name series the same way. An entry is a record id plus a series type, optionally narrowed to one column with name:
{"record": "01a07fd7-01bc-7de7-9b63-c0310318cbac", "type": "power-forecast", "name": "q50"}Results come back in request order with an index, so they can be matched without re-deriving identity. Entries that do not resolve (an unknown record, no such series, a record in another workspace) come back as per-item error objects with on_missing="skip" (the default), so a fixed manifest needs no pre-checking; on_missing="error" fails the whole request instead. An item with an error and an item with no points are different things: the first did not resolve, the second holds nothing in the window.
Write many series
POST /timeseries/write is the mirror of the read. Each entry is exactly a per-record upload body (Upload / read timeseries) plus the record it goes to. Every entry is checked and its series declared before anything is written; the values then go to the store in one pass, so a batch lands whole or not at all. An unknown record is a 404, a bad body a 422, and nothing is written in either case.
r = requests.post(f"{BASE}/timeseries/write", headers=HEADERS, json={
"series": [
{"record": utsira_id, "type": "power-production", "shape": "singleindex", "unit": "MW",
"points": [{"valid_time": "2026-09-22T10:00:00Z", "value": 0.81}]},
{"record": smola_id, "type": "power-production", "shape": "singleindex", "unit": "MW",
"points": [{"valid_time": "2026-09-22T10:00:00Z", "value": 31.4}]},
{"record": icx_id, "type": "flow", "shape": "singleindex", "unit": "MW",
"points": [{"valid_time": "2026-09-22T10:00:00Z", "value": -140.0}]},
],
})
r.raise_for_status()
print(r.json())
# {"items": [{"index": 0, "record": "...", "type": "power-production", "shape": "singleindex",
# "unit": "MW", "columns": [], "points": 1}, ...],
# "points_written": 3, "points_skipped": 0}items echo what each entry submitted (points is its value cells). The written and skipped counts are per batch, because the store dedups the whole batch in one pass. skip_unchanged (default true) applies to the batch and can be overridden per entry; retention, knowledge_time, and per-point annotation work per entry exactly as on a single upload. Limits: 2,000 entries and 1,000,000 values per call.
Read many series
POST /timeseries/read reads a whole manifest in one call. Entries that share a window resolve in one database query, so thousands of series cost one round trip instead of thousands.
manifest = [{"record": site_id, "type": "power-production"} for site_id in site_ids]
manifest.append({"record": icx_id, "type": "flow"}) # a connection's series
manifest.append({"record": site_ids[0], "type": "power-forecast", "name": "q50"}) # one column
r = requests.post(f"{BASE}/timeseries/read", headers=HEADERS, json={
"series": manifest,
"start": "2026-09-04T00:00:00Z",
"end": "2026-09-11T00:00:00Z",
"bucket_seconds": 900, # 15-minute means, decided once for the whole batch
"format": "columns", # times + one list per column instead of row objects
})
r.raise_for_status()
for item in r.json()["items"]:
if item.get("error"):
continue # unknown record or no such series
print(item["index"], item["record"], item["type"], len(item["times"]))- Window and aggregation come from the top level and can be overridden per entry, with exactly the single-read meaning:
version(latestorall),as_of,knowledge_time,max_points,bucket_seconds. - Aggregation joins the batch when the entries state their window (
startandend): every entry in a group is bucketed alike, decided by one density check (bucket_secondsalways applies;max_pointsbuckets only when the group is denser than the cap). An aggregating entry without a window reads on its own, since its bucket has to come from the series' own extent. Bucketed items reportbucket_seconds. format=columnsreturns, per item, onetimeslist (plusknowledge_timesforversion=all) and one aligned list per column invalues(valuefor the plain single-column form), instead of row-wisepoints. Same data, a fraction of the bytes for wide or dense reads. Combine with gzip.- Limits: 10,000 series per request and 5,000,000 values. Exceeding the value budget is a
422that tells you to narrow the window or passmax_points/bucket_seconds. - Batched items omit
series_first/series_last/runs. Use the single-series read when you need a range picker or a run list, or stats for the extents of many series at once.
Which series exist
POST /timeseries/catalog answers "which series exist on which records" for many records in one call, so a consumer can build its manifest without one listing call per record.
r = requests.post(f"{BASE}/timeseries/catalog", headers=HEADERS, json={
"records": site_ids,
"types": ["power-production", "power-forecast"], # optional: keep these series types
})
r.raise_for_status()
for item in r.json()["items"]:
print(item["record"], item["object"], [(s["type"], s["columns"]) for s in item["series"]])Items carry the record id, its object type slug, and series: [{type, shape, unit, columns}]. Records that do not exist (or are in another workspace) are left out; a record without series has an empty list. Up to 10,000 records per call.
Latest values: the "now" of a fleet
POST /timeseries/latest returns the current value of every named series without reading a window: per entry and column, the point at the latest stored time, at or before end, as known by as_of.
r = requests.post(f"{BASE}/timeseries/latest", headers=HEADERS, json={
"series": [{"record": site_id, "type": "power-production"} for site_id in site_ids],
# "end": "2026-09-22T12:00:00Z", # the latest point at or before this instant
# "as_of": "...", # as known at this instant
})
r.raise_for_status()
for item in r.json()["items"]:
if item.get("error"):
continue
for column, point in item["latest"].items():
print(item["record"], item["type"], column, point["valid_time"], point["value"])latest is keyed by column (value for the plain single-column form). A column with no stored point is absent, and a null value means the latest stored point is a recorded gap. Two database queries for the whole list, whatever the series' density, so this is the right way to build a snapshot every few seconds.
Statistics: extents and per-bucket counts
POST /timeseries/stats describes many series without shipping their values. Per entry and column it returns an extent (first and last valid time, latest knowledge and change time, stored rows and distinct points in the window) and, with bucket_seconds, buckets of count, min, max, mean, first, last over the resolved view.
r = requests.post(f"{BASE}/timeseries/stats", headers=HEADERS, json={
"series": [{"record": site_id, "type": "power-production"} for site_id in site_ids],
"start": "2026-09-20T00:00:00Z",
"end": "2026-09-22T00:00:00Z",
"bucket_seconds": 900,
})
r.raise_for_status()
for item in r.json()["items"]:
for column, stats in item["columns"].items():
extent = stats["extent"]
if extent is None:
continue # nothing stored in the window
filled = len(stats["buckets"]) # 15-minute buckets with data
print(item["record"], column, extent["last"], extent["last_change_time"], filled, "of 192")Buckets with no points are absent, which is what a freshness check ("is there a point in every 15 minute bucket of the last 48 hours") or a coverage report wants to know. bucket_seconds needs start and end; without it the call returns extents only, over everything stored. extent is null for a column with no rows in the window. Up to 1,000,000 buckets per call.
Putting it together: serving a map
One consumer's day ties the routes together:
- Once, and after any structural change: read the records (export is the cheapest way) to map your own keys to record ids, then
/timeseries/catalogfor those ids to learn which series each record has. Cache both. - Every few minutes: one
/timeseries/readfor the whole manifest withbucket_secondsandformat: "columns", gzip on. A few hundred series over a day at 15 minutes is one call, well under a second and a few tens of kilobytes on the wire. - Every few seconds:
/timeseries/latestfor the "now" panel. - Every few minutes, in the background:
/timeseries/statswith 15-minute buckets over the last 48 hours for a freshness matrix, and without buckets for each series' last change time.
Nothing in that consumer needs a database connection or a per-record call.