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\""]}}.

MethodPathFlagPurpose
POST/metric/datadata PPublish measurements
POST/metric/query/rawdata RThe stored points
POST/metric/query/resampledata REvenly spaced estimates over a range
POST/metric/query/atdata ROne estimate per series at one x
DELETE/metric/data/{group}data WPurge a Group
DELETE/metric/data/{address}data WPurge one series
GET/metric/auditaudit_flags RThe audit log
…/metric/defaults, /metric/groups/…registry_flagsRetention and defaults, Addresses
…/metric/addresses/…registry_flagsAddresses
…/metric/discriminations/…registry_flagsDiscriminations

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:

AddressTag (decimal)dtypeName
000C:3E0115873sns_f32Boiler temperature
000C:3E0215874sns_f32Return temperature
000C:300012288sns_d8Boiler state
000C:4F0120225cts_f64Flow, 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:

ValueIn JSONExample
y u8 u16 u32 i8 i16 i32 d8 d16 d32a JSON integer, in range200
y f16 f32 f64; x f16 f32a JSON number23.5
y u64 i64 d64a 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 fieldRules
measurements1 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:

JSON
{
	"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.

JSON
{
	"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.

JSON
{ "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:

FieldRequiredMeaning
groupsyesGroups ("GGGG") and [from, to] ranges, e.g. ["000C", ["0064", "00C7"]].
tagsnoTags ("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 R on every selected Group, else 403.
  • 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:00 works too); a float x is a number. A time selects the time Tags (x digit 0–4), a number the float Tags (5, 6); Tags of the other kind are left out. from and until must be of the same kind.

Every response is a list with one entry per series:

FieldMeaning
addressThe series, "000C:3E01".
dtype, dtype_nameIts dtype as hex and by name: "3E", "sns_f32".
nameThe 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 x renders as ISO 8601 UTC with up to 9 decimals, trailing zeros dropped; a float x as a number;
  • a stored y (raw) renders as a JSON number, except u64, i64 and d64, which are decimal strings whatever their value ("0", "18446744073709551615"); floats keep their decimal point (24.0), and f16 / f32 values 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 for d64);
  • an estimate (resample, at) of a numeric Tag is a float, so always a JSON number, even for a u64 Tag: use raw for exact 64-bit values. null means nothing to estimate from.
TypeScript
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).

FieldMeaning
fromOptional, inclusive.
untilOptional, exclusive.
order"asc" (default) or "desc".
limitPoints per series: default 1000, 1–10000 (else 422).
Shell
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}'
JSON
{
	"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.

FieldMeaning
fromRequired: the first sample.
untilRequired, excluded; after from.
pointsRequired, 2–10000.
JSON
{
	"groups": ["000C"],
	"tags": [["3E00", "3EFF"]],
	"from": "2026-11-02T10:00:00Z",
	"until": "2026-11-02T11:00:00Z",
	"points": 4
}
JSON
{
	"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?"

Shell
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"}'
JSON
{
	"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.

Shell
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"
JSON
{ "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.

QueryMeaning
limitDefault 100, 1–1000 (else 422).
groupOnly this Group's entries, as 4 hex digits (000C).
beforeAn entry id: only older entries. Page with the last one.
Shell
curl -s "https://api.thuum.dev/api/acme/metric/audit?group=000C&limit=3" -H "authorization: Bearer $TOKEN"
JSON
{
	"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.

MethodPathPurpose
GET/metric/defaultsThe Domain's effective defaults.
PUT/metric/defaultsSet { retention_days, max_points }: retention_days required, max_points null = no cap.
DELETE/metric/defaultsBack to the system default (204, idempotent).
GET/metric/groupsThe 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:

JSON
{
	"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):

Shell
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}'
JSON
{
	"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":

Shell
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}'
JSON
{
	"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 name but retention_days null (or omitted) is registered and keeps following the Domain's defaults (source "domain" or "system", with their retention_days).

  • max_points without a retention_days is a 422 (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_days are a 422 on retention_days:

    SentMessage
    0, -1must be at least 1 day
    1000001must be at most 1000000 days (max_retention_days)
    1.5, "90"must be a whole number of days (an integer)
    null or nothing, on PUT /defaultscan't be blank: DELETE the defaults to fall back to the system's
  • The former retention field ("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: null and null timestamps.

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.

MethodPathPurpose
GET/metric/groups/{group}/addressesThe 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}/discriminationLink { discrimination_id }, replacing any link.
DELETE/metric/addresses/{address}/discriminationUnlink (204, idempotent).
Shell
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"}'
JSON
{
	"data": {
		"address": "000C:3E01",
		"dtype": "3E",
		"dtype_name": "sns_f32",
		"name": "Boiler temperature",
		"interpolation": "linear",
		"discrimination_id": null,
		"registered": true
	}
}
  • interpolation is "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 unknown discrimination_id, is a 422.
  • 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: null and interpolation: "step-hold".
  • The entry belongs to this Tag in this Group: 000D:3E01 has its own, apart from 000C:3E01.

Discriminations#

A discrimination is a dictionary from a discrete key to a text: the labels of a discrete Tag.

MethodPathPurpose
GET/metric/discriminationsList, by name, with items and linked addresses.
POST/metric/discriminationsCreate { 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');
JSON
{
	"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.