REST API
Metric Broker REST
Publish measurements; query them raw, resampled or at one moment; purge; read the audit log; and manage retention, Tag names, interpolation and discriminations.
All routes are below https://api.thuum.dev/api/{domain}/metric. Data routes check the key's Metric
Broker claim data flags on every Group they touch; registry routes check its registry_flags,
and the audit log its audit_flags (see Claims and permissions).
A key without a Metric Broker claim gets 403 on all of them. Groups and addresses in paths and
bodies are in their hex text form: Group 000C, address
000C:3E01 (Group 000C, Tag 3E01). Anything else (12, 0x000C, C:3E01) is a 422, for
example {"errors": {"group": ["must be a Group: 4 hex digits, e.g. \"000C\""]}}.
| Method | Path | Flag | Purpose |
|---|---|---|---|
POST | /metric/data | data P | Publish measurements |
POST | /metric/query/raw | data R | The stored points |
POST | /metric/query/resample | data R | Evenly spaced estimates over a range |
POST | /metric/query/at | data R | One estimate per series at one x |
DELETE | /metric/data/{group} | data W | Purge a Group |
DELETE | /metric/data/{address} | data W | Purge one series |
GET | /metric/audit | audit_flags R | The audit log |
| … | /metric/defaults, /metric/groups/… | registry_flags | Retention and defaults, Addresses |
| … | /metric/addresses/… | registry_flags | Addresses |
| … | /metric/discriminations/… | registry_flags | Discriminations |
Metric Broker claims themselves are managed under /metric/claims: see
Management. With @thuum/client, every route is a method of
acme.metricBroker (reference).
The examples use Group 000C (12), a boiler room, with these addresses:
| Address | Tag (decimal) | dtype | Name |
|---|---|---|---|
000C:3E01 | 15873 | sns_f32 | Boiler temperature |
000C:3E02 | 15874 | sns_f32 | Return temperature |
000C:3000 | 12288 | sns_d8 | Boiler state |
000C:4F01 | 20225 | cts_f64 | Flow, timed by the meter |
Publish#
POST /metric/data stores measurements, then delivers them to WebSocket subscribers, exactly like a
WebSocket pub. Each measurement has the fields and values of a JSON pub: address, x (only
for client-set x) and y, as plain JSON values typed by the Tag's dtype:
| Value | In JSON | Example |
|---|---|---|
y u8 u16 u32 i8 i16 i32 d8 d16 d32 | a JSON integer, in range | 200 |
y f16 f32 f64; x f16 f32 | a JSON number | 23.5 |
y u64 i64 d64 | a decimal string, always | "18446744073709551615" |
a time x (cts) | an ISO 8601 string, Z or ±HH:MM, ≤ 9 decimals | "2026-11-02T10:00:00Z" |
No base64, no byte layouts: 64-bit integers and times are strings so that no JSON parser rounds
them to a double, and f16 / f32 values are rounded to their type. The rules and the reasons are
in Values in JSON.
| Body field | Rules |
|---|---|
measurements | 1 to 1000 of { address, x?, y }. All are stored, or none. |
The examples publish an sns_f32 reading (the server sets x), a cts_f64 one with the meter's own
time, and an sns_u64 counter (address 000C:3701):
curl -s -X POST https://api.thuum.dev/api/acme/metric/data \
-H "authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d '{"measurements":[
{"address":"000C:3E01","y":23.5},
{"address":"000C:4F01","x":"2026-11-02T10:00:00Z","y":23.5},
{"address":"000C:3701","y":"18446744073709551615"}]}'import { fromJsonMeasurement, parseTime } from '@thuum/client';
// Values are checked and written for each Tag's dtype: numbers, and bigint (or the JSON string
// form) for times and 64-bit integers.
const stored = await acme.metricBroker.data.publish([
{ address: '000C:3E01', y: 23.5 }, // sns_f32: the server sets x
{ address: '000C:4F01', x: parseTime('2026-11-02T10:00:00Z'), y: 23.5 }, // cts_f64
{ address: '000C:3701', y: 18446744073709551615n } // sns_u64
]);
const [first] = stored.map(fromJsonMeasurement); // { address: '000C:3E01', group: 12, tag: 15873, x: 1793613600500000000n, y: 23.5 }const response = await fetch(`${API}/api/acme/metric/data`, {
method: 'POST',
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
body: JSON.stringify({
measurements: [
{ address: '000C:3E01', y: 23.5 }, // sns_f32: the server sets x
{ address: '000C:4F01', x: '2026-11-02T10:00:00Z', y: 23.5 }, // cts_f64
{ address: '000C:3701', y: (2n ** 64n - 1n).toString() } // sns_u64: a string, always
]
})
});
const { data } = await response.json();import requests
response = requests.post(
f"{API}/api/acme/metric/data",
json={"measurements": [
{"address": "000C:3E01", "y": 23.5}, # sns_f32: the server sets x
{"address": "000C:4F01", "x": "2026-11-02T10:00:00Z", "y": 23.5}, # cts_f64
{"address": "000C:3701", "y": str(2**64 - 1)}, # sns_u64: a string, always
]},
headers={"authorization": f"Bearer {token}"},
timeout=10,
)
response.raise_for_status()Response 201, every measurement as stored: with the x the server set, times in UTC, f16
/ f32 values as rounded, 64-bit integers as strings, addresses in uppercase:
{
"data": [
{ "address": "000C:3E01", "x": "2026-11-02T10:00:00.5Z", "y": 23.5 },
{ "address": "000C:4F01", "x": "2026-11-02T10:00:00Z", "y": 23.5 },
{
"address": "000C:3701",
"x": "2026-11-02T10:00:00.500000417Z",
"y": "18446744073709551615"
}
]
}An f32 y of 0.1 would come back as 0.10000000149011612, the nearest f32; a cts time sent
as "2026-11-02T11:00:00+01:00" comes back as "2026-11-02T10:00:00Z".
Errors. Nothing is stored either way. Every measurement is checked first, in full (address,
dtype, values): invalid ones are a 422 naming each by its index, from 0, with the reason. Only
then the flags: measurements on Groups without P are a 403 listing their indexes. Unlike the
WebSocket, where each op is answered on its own and the claim comes before the values
(order of checks), a publish is one all-or-nothing
request: if anything in it is malformed, that is reported first, even on a Group you may not
publish to.
{
"errors": {
"measurements[1]": ["y must be a number (f32)"],
"measurements[2]": ["y must be a decimal string (u64)"]
}
}That is the answer to {"y": "23.5"} on an f32 Tag and {"y": 42} on a u64 Tag. Other reasons:
address must be an address: 4 + 4 hex digits, e.g. "000C:3E01" ("12:3E01", a number),
Tag 7C01 has dtype 7C, which is not valid, y must be an integer (u8) (1.5), y must be from 0 to 255 (u8) (256), y must be from 0 to 18446744073709551615 (u64) ("18446744073709551616"),
y must be a number within the f32 range (3.5e38), x must be an ISO 8601 time from 1970-01-01T00:00:00Z to 2262-04-11T23:47:16.854775807Z (a number, a lowercase t or z, an offset
without a colon), x must be absent for sns_f32: the server sets it, x is required for cts_f64,
y is required.
{ "errors": { "detail": "Forbidden", "indexes": [1, 2] } }An empty list or more than 1000 is {"errors": {"measurements": ["must be a list of 1 to 1000 {address, x?, y}"]}}.
With the client, metricPublishErrors(error) reads both shapes as { status, indexes, messages };
a value the client can tell is wrong (256 for a u8) throws MetricCodecError before any request.
Queries#
Queries are POST with a JSON body, because their filters take lists and ranges. All three select
series the same way:
| Field | Required | Meaning |
|---|---|---|
groups | yes | Groups ("GGGG") and [from, to] ranges, e.g. ["000C", ["0064", "00C7"]]. |
tags | no | Tags ("TTTT"), same form, e.g. ["3E01", ["3E00", "3EFF"]]. Omitted: every Tag. |
The two lists combine: every selected Tag in every selected Group. That is why a query takes Groups
and Tags apart rather than addresses: "groups": [["0064", "00C7"]] with "tags": [["3E00", "3EFF"]] is every sns_f32 series of Groups 100 to 199, which no single range of addresses can
express. Each value is exactly 4 hex digits (either case), and from <= to; anything else is a
422 (groups: must be a non-empty list of Groups ("000C") and [from, to] ranges, from <= to).
- The key needs
Ron every selected Group, else403. - The selected series are those that have received data, ordered by address; at most 256 per query,
else
422(groups: select more than 256 series: narrow the selection). - x values: a time is ISO 8601 with up to 9 decimals (
"2026-11-02T10:00:00.5Z"; an offset such as+01:00works too); a floatxis a number. A time selects the time Tags (x digit0–4), a number the float Tags (5,6); Tags of the other kind are left out.fromanduntilmust be of the same kind.
Every response is a list with one entry per series:
| Field | Meaning |
|---|---|
address | The series, "000C:3E01". |
dtype, dtype_name | Its dtype as hex and by name: "3E", "sns_f32". |
name | The Tag's name, null if it has none. |
| … | The query's own fields: points (and truncated), or x and y. |
Values follow the JSON encoding (Values in JSON):
- a time
xrenders as ISO 8601 UTC with up to 9 decimals, trailing zeros dropped; a floatxas a number; - a stored
y(raw) renders as a JSON number, exceptu64,i64andd64, which are decimal strings whatever their value ("0","18446744073709551615"); floats keep their decimal point (24.0), andf16/f32values show what is stored (0.10000000149011612); - a discrete Tag renders its item's text, else the key as the JSON encoding writes it (a number for
d8–d32, a string ford64); - an estimate (
resample,at) of a numeric Tag is a float, so always a JSON number, even for au64Tag: userawfor exact 64-bit values.nullmeans nothing to estimate from.
const series = await acme.metricBroker.query.resample({
groups: ['000C'],
tags: [['3E00', '3EFF']], // every sns_f32 Tag
from: new Date('2026-11-02T10:00:00Z'), // a Date, a bigint of ns or ISO text
until: new Date('2026-11-02T11:00:00Z'),
points: 4
});raw#
The stored points in [from, until).
| Field | Meaning |
|---|---|
from | Optional, inclusive. |
until | Optional, exclusive. |
order | "asc" (default) or "desc". |
limit | Points per series: default 1000, 1–10000 (else 422). |
curl -s -X POST https://api.thuum.dev/api/acme/metric/query/raw \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"groups":["000C"],"tags":["3E01"],"from":"2026-11-02T10:00:00Z","until":"2026-11-02T11:00:00Z","limit":3}'{
"data": [
{
"address": "000C:3E01",
"dtype": "3E",
"dtype_name": "sns_f32",
"name": "Boiler temperature",
"points": [
["2026-11-02T10:00:00.5Z", 23.5],
["2026-11-02T10:00:01.25Z", 23.75],
["2026-11-02T10:00:02Z", 24.0]
],
"truncated": true
}
]
}truncated is true when more points remain in the range. For the next page, pass the last x as
until with "order": "desc", or as from with "order": "asc" (from is inclusive: drop the
point you already have).
A 64-bit series renders its values as strings, exactly: the sns_u64 counter of
Publish gives "points": [["2026-11-02T10:00:00.500000417Z", "18446744073709551615"]].
resample#
points estimates at from + i × (until − from) / points, for i from 0 to points − 1, by each
Tag's interpolation. Ask for about as many points as
your chart has pixels across.
| Field | Meaning |
|---|---|
from | Required: the first sample. |
until | Required, excluded; after from. |
points | Required, 2–10000. |
{
"groups": ["000C"],
"tags": [["3E00", "3EFF"]],
"from": "2026-11-02T10:00:00Z",
"until": "2026-11-02T11:00:00Z",
"points": 4
}{
"data": [
{
"address": "000C:3E01",
"dtype": "3E",
"dtype_name": "sns_f32",
"name": "Boiler temperature",
"points": [
["2026-11-02T10:00:00Z", 23.5],
["2026-11-02T10:15:00Z", 23.875],
["2026-11-02T10:30:00Z", 24.25],
["2026-11-02T10:45:00Z", 24.0]
]
},
{
"address": "000C:3E02",
"dtype": "3E",
"dtype_name": "sns_f32",
"name": "Return temperature",
"points": [
["2026-11-02T10:00:00Z", null],
["2026-11-02T10:15:00Z", null],
["2026-11-02T10:30:00Z", 19.75],
["2026-11-02T10:45:00Z", 20.0]
]
}
]
}Estimates use the stored points around each sample, even outside the range, so the first
sample of a chart is right even when the last reading came in before from. A sample with nothing
stored at or before it is null (000C:3E02 started reporting at 10:20). Estimates of integer y
are floats, even where a point is stored exactly at the sample.
at#
One estimate per series at x (required): "what was the value then?"
curl -s -X POST https://api.thuum.dev/api/acme/metric/query/at \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"groups":["000C"],"tags":["3000"],"x":"2026-11-02T03:12:00Z"}'{
"data": [
{
"address": "000C:3000",
"dtype": "30",
"dtype_name": "sns_d8",
"name": "Boiler state",
"x": "2026-11-02T03:12:00Z",
"y": "HOT"
}
]
}The Boiler state is discrete, so it is always step-hold, and its key renders as the linked
discrimination's text. For float-x Tags, x is a number:
{"groups":["000C"],"x":1.5} selects the Group's float-x series only.
Purge#
DELETE /metric/data/{group} deletes a Group's points (/metric/data/000C), DELETE /metric/data/{address} one series' (/metric/data/000C:3E01): the colon tells them apart. Optional
query parameters: from (inclusive) and until (exclusive), of the same kind. An ISO 8601 time
purges time Tags only; a value that parses as a number is a float bound and purges float Tags
only; without filters, everything goes. Series left without points are removed too. Subscribers
are not notified.
curl -s -X DELETE \
"https://api.thuum.dev/api/acme/metric/data/000C:3E01?from=2026-11-01T00:00:00Z&until=2026-11-02T00:00:00Z" \
-H "authorization: Bearer $TOKEN"{ "data": { "deleted": 86400 } }Audit log#
GET /metric/audit returns the audit log, newest first: every :ok reply the WebSocket sent (ack
forms) and every :error reply; not the JSON error ops, which have no Group. Entries are written
within about a second, and follow their Group's retention.
| Query | Meaning |
|---|---|
limit | Default 100, 1–1000 (else 422). |
group | Only this Group's entries, as 4 hex digits (000C). |
before | An entry id: only older entries. Page with the last one. |
curl -s "https://api.thuum.dev/api/acme/metric/audit?group=000C&limit=3" -H "authorization: Bearer $TOKEN"{
"data": [
{
"id": 4182,
"op": "pub:error",
"address": "000C:3E01",
"group": null,
"x": null,
"code": "forbidden",
"inserted_at": "2026-11-02T10:00:01.104382Z"
},
{
"id": 4181,
"op": "pub:ok",
"address": "000C:4F01",
"group": null,
"x": "2026-11-02T10:00:00Z",
"code": null,
"inserted_at": "2026-11-02T10:00:01.104382Z"
},
{
"id": 4180,
"op": "sub-group:ok",
"address": null,
"group": "000C",
"x": null,
"code": null,
"inserted_at": "2026-11-02T10:00:00.912007Z"
}
]
}An entry has either an address, for the ops on one series (pub, sub, unsub), or a group,
for sub-group and unsub-group; the other is null. x is the measurement's, as REST renders it
(an ISO 8601 time or a number), or null: for ops without one, and for an error on a server-time
pub (it was sent without x). code is the error's name. With the client:
acme.metricBroker.audit({ group: '000C', limit: 100, before }).
Retention and defaults#
Retention is set per Group, as a number of days you choose, retention_days: a whole number from
1 up, with no practical limit (at most 1000000, about 2 700 years), with an optional max_points cap (the newest points are kept). A Group's
window applies as a whole: its own retention_days with its own max_points, or else the
Domain's defaults (cap included), else the system default of 365 days, no cap.
| Method | Path | Purpose |
|---|---|---|
GET | /metric/defaults | The Domain's effective defaults. |
PUT | /metric/defaults | Set { retention_days, max_points }: retention_days required, max_points null = no cap. |
DELETE | /metric/defaults | Back to the system default (204, idempotent). |
GET | /metric/groups | The registered Groups, in order. |
GET | /metric/groups/{group} | One Group, registered or not, with its effective retention. |
PUT | /metric/groups/{group} | Set { name, retention_days, max_points } (replaces all three; omitted = null, the Domain's window). |
DELETE | /metric/groups/{group} | Remove the entry (204, idempotent): the data stays, retention falls back. |
A Domain without defaults answers GET /metric/defaults with the system's:
{
"data": {
"retention_days": 365,
"ttl_seconds": 31536000,
"max_points": null,
"source": "system",
"inserted_at": null,
"updated_at": null
}
}Set your Domain's own (every Group without a window of its own follows them):
curl -s -X PUT https://api.thuum.dev/api/acme/metric/defaults \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"retention_days":90,"max_points":500}'{
"data": {
"retention_days": 90,
"ttl_seconds": 7776000,
"max_points": 500,
"source": "domain",
"inserted_at": "2026-11-02T09:57:40Z",
"updated_at": "2026-11-02T09:57:40Z"
}
}retention_days is required there (DELETE the defaults to go back to the system's). A Group
renders its effective window, with source "group", "domain" or "system":
curl -s -X PUT https://api.thuum.dev/api/acme/metric/groups/000C \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"name":"Boiler room","retention_days":400,"max_points":null}'{
"data": {
"group": "000C",
"name": "Boiler room",
"retention_days": 400,
"ttl_seconds": 34560000,
"max_points": null,
"source": "group",
"registered": true,
"inserted_at": "2026-11-02T09:58:12Z",
"updated_at": "2026-11-02T09:58:12Z"
}
}A Group with a
namebutretention_daysnull(or omitted) is registered and keeps following the Domain's defaults (source"domain"or"system", with theirretention_days).max_pointswithout aretention_daysis a422(max_points: needs retention_days: without it the Group uses its Domain's defaults): without its own retention, the Group uses the Domain's window, cap included.Invalid
retention_daysare a422onretention_days:Sent Message 0,-1must be at least 1 day1000001must be at most 1000000 days (max_retention_days)1.5,"90"must be a whole number of days (an integer)nullor nothing, onPUT /defaultscan't be blank: DELETE the defaults to fall back to the system'sThe former
retentionfield ("3m","1y","5y") is refused, not ignored:retention: is no longer accepted: use retention_days.Thuum's own Playground keeps
retention_days: 1, at most 1000 points per Group.An unregistered Group renders
registered: false,name: nullandnulltimestamps.
Points are removed once a day, by when they were stored: a point may outlive its retention by up to a day.
Addresses#
The registry of Tags is by address: the metadata (name, interpolation, discrimination) of one Tag in one Group.
| Method | Path | Purpose |
|---|---|---|
GET | /metric/groups/{group}/addresses | The Group's addresses: every series with data, an entry or a discrimination link, in order. Filter: ?dtype=3E. |
GET | /metric/addresses/{address} | One address, registered or not. |
PUT | /metric/addresses/{address} | Set { name, interpolation } (omitted interpolation = "step-hold"). |
DELETE | /metric/addresses/{address} | Remove the entry and its discrimination link (204). The data stays. |
PUT | /metric/addresses/{address}/discrimination | Link { discrimination_id }, replacing any link. |
DELETE | /metric/addresses/{address}/discrimination | Unlink (204, idempotent). |
curl -s -X PUT https://api.thuum.dev/api/acme/metric/addresses/000C:3E01 \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"name":"Boiler temperature","interpolation":"linear"}'{
"data": {
"address": "000C:3E01",
"dtype": "3E",
"dtype_name": "sns_f32",
"name": "Boiler temperature",
"interpolation": "linear",
"discrimination_id": null,
"registered": true
}
}interpolationis"step-hold"(the default),"linear"or"spline"; discrete Tags take"step-hold"only (422).- Only discrete Tags (y digit
0–3) can link a discrimination, and only one of the Domain's: another Tag, or an unknowndiscrimination_id, is a422. - An address whose Tag has an invalid dtype is a
422({"errors": {"address": ["Tag 7C01 has dtype 7C, which is not valid"]}}); so is?dtype=with anything but two hex digits of a valid dtype. - An unregistered address renders
registered: false,name: nullandinterpolation: "step-hold". - The entry belongs to this Tag in this Group:
000D:3E01has its own, apart from000C:3E01.
Discriminations#
A discrimination is a dictionary from a discrete key to a text: the labels of a discrete Tag.
| Method | Path | Purpose |
|---|---|---|
GET | /metric/discriminations | List, by name, with items and linked addresses. |
POST | /metric/discriminations | Create { name, description, items? } (201). |
GET | /metric/discriminations/{id} | Read one, with its items and linked addresses. |
PUT | /metric/discriminations/{id} | Update name, description (the same as PATCH). |
PATCH | /metric/discriminations/{id} | Update the fields given. |
DELETE | /metric/discriminations/{id} | Delete with its items (204); 409 while linked to a Tag. |
PUT | /metric/discriminations/{id}/items/{key} | Set an item: { value }. Returns the discrimination. |
DELETE | /metric/discriminations/{id}/items/{key} | Remove an item (204, idempotent). |
ID=$(curl -s -X POST https://api.thuum.dev/api/acme/metric/discriminations \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"name":"Boiler state","items":{"1":"HOT","2":"COLD"}}' | jq -r .data.id)
curl -s -X PUT https://api.thuum.dev/api/acme/metric/addresses/000C:3000/discrimination \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d "{\"discrimination_id\":\"$ID\"}"const boiler = await acme.metricBroker.discriminations.create({
name: 'Boiler state',
items: { '1': 'HOT', '2': 'COLD' }
});
await acme.metricBroker.addresses.discrimination.link('000C:3000', boiler.id);
await acme.metricBroker.discriminations.items.put(boiler.id, 3, 'WARM');{
"data": {
"id": "5f0c2b8e-9a41-4d7e-8c3a-1e2f6b7d9a10",
"name": "Boiler state",
"description": null,
"items": { "1": "HOT", "2": "COLD" },
"addresses": ["000C:3000"],
"inserted_at": "2026-11-02T09:58:40Z",
"updated_at": "2026-11-02T09:58:40Z"
}
}Keys go from 0 to 2^64 − 1, in decimal (pass a bigint to the client beyond 2^53); values are
non-empty text (422). Names need not be unique. One discrimination may label many Tags; each Tag
links to at most one. Deleting a linked one answers 409 Discrimination is still linked to a Tag; unlink it first.