Online Metering Data Collection Platform — machine/IoT API.
Authentication: every request carries `Authorization: Bearer <token>`.
Tokens are created in *Settings → API tokens*, are bound to exactly one
tenant, and carry scopes. Reporting requires the `data:report` scope;
the read/query endpoints require `data:read`.
Rate limits: 60 requests/minute per token. Responses include
`X-RateLimit-Limit` / `X-RateLimit-Remaining`.
**Not a legally valid metering data processing system.** The values
this API returns are calculated, converted and, where data is
missing, interpolated, and are therefore not verified measurement
results — not even where the recording device itself is verified.
They must not be used for purposes requiring verified instruments,
in particular not for billing consumption to third parties — see
clause 6.2 of the Terms.
This is stated once, here, rather than as a field on every reading:
a notice repeated in every JSON object is payload an integration
pays for on every request and reads never. The UI exports carry it
IN the file instead (#93), because a spreadsheet gets forwarded to
people who never saw this document, whereas the party integrating
against this API is the customer who accepted those Terms.
bearerAuth: Http bearer — API token from Settings → API tokens (prefix `bmm_`)
webhookToken: Http bearer — The endpoint's OWN token from Settings → Webhook ingest (#102) — a different credential from the API tokens above, and not interchangeable with them. Each webhook endpoint has one, shown once when it is issued. Where the sending system cannot set an `Authorization` header, the same value may be sent as `X-Webhook-Token`; there is deliberately no query-parameter form, because a token in a URL lands in access logs and browser history.
GET/api/v1/ping
Authenticated liveness probe
Confirms the API is reachable and the token valid, and reports
whether the installation's scheduler needs attention.
`status` is `degraded` — still HTTP 200 — when the scheduler has
stopped reporting in **or was never set up at all**. The
application still serves requests correctly in that state, so a
load balancer must not withdraw it; what has stopped is
retention pruning and alert evaluation. A monitor should treat
anything other than `ok` as needing a human.
`scheduler.ever_ran` separates the two causes: `false` means no
cron line is running yet, `true` with `stale: true` means one
was running and stopped.
Responses
Status
Description
Body
200
API reachable and token valid
Field
Type
Description
status
string
ok | degraded
application
string
time
string
scheduler
object
Field
Type
Description
last_run_at
string, null
ever_ran
boolean
stale
boolean
401
Missing, unknown, revoked or expired token
Field
Type
Description
message
string
429
Per-token rate limit exceeded
Field
Type
Description
message
string
POST/api/v1/reportData
Report consumption / saldo readings (batch)
Reports one or many readings for meters of the token's tenant.
- Meter identifiers unknown to the tenant are **auto-provisioned**
(filed under "Ungrouped meters"). Auto-provisioning is bounded
per tenant (default 100 new meters/hour); beyond the cap, items
for NEW identifiers return `status: rejected_provision_limit`
while readings for existing meters are unaffected.
- On the hosted service a **plan meter limit** applies as well.
At the limit, items for NEW identifiers return
`status: rejected_plan_limit` and the workspace owner needs to
upgrade. Readings for meters that ALREADY EXIST are never
refused for a plan or payment reason — meter history cannot be
reconstructed once a device has moved on, so the commercial
lever is access to the data, never acceptance of it.
- **Idempotent:** a reading is identified by
`(meter, kind, recorded_at)`; retries of the same report are
answered with `status: duplicate` and never double-count.
- **Model-aware:** every item is validated against the meter's
assigned device profile (see `profile` on `/meters`). Meters
on the *generic* profile report the plain
`kind`/`value`/`unit` shape; meters assigned another model
report a `payload` object matching that profile's schema
(e.g. the IZAR register reading plus status flags). The
payload is normalized into canonical readings; the raw
payload is retained on the stored reading for traceability.
- **Pulse counters** (#44) report `pulses` (absolute counter)
and/or `pulse_delta` (increment since the last report); the
per-meter **pulse weight** ("1 pulse = weight × unit",
configured in the meter settings) converts counts into
measurements. Items for meters without that configuration
are answered per item with `status: rejected_unconfigured` —
the meter itself IS provisioned, so it can be configured and
the next report ingests. Multi-channel pulse devices report
each channel as its own meter identifier.
- The optional `profile` field declares the device's model
explicitly: an unknown meter is then auto-provisioned
directly onto that profile (nothing left to review), and a
meter still on the unconfirmed provisioning default is
upgraded to the declared model. A declaration that
contradicts a *user-confirmed* profile assignment is
rejected — reassign the meter in settings first.
- **Atomic validation:** the whole batch is validated before
anything is stored. Any schema violation rejects the entire
request with a 422 naming the offending field
(e.g. `readings.3.payload.value`); nothing is written.
Request body
Field
Type
Description
readings*
array
One of:
Field
Type
Description
meter*
string
Device identifier, unique within the tenant
profile
string
Optional explicit model declaration (catalog key)
kind*
string
consumption | saldo
`consumption` = incremental delta since the previous report; `saldo` = absolute register/balance value.
value*
number
unit*
string
kwh | wh | m3 | l
recorded_at*
string
Device-side measurement time (ISO 8601). Must not lie more than 5 minutes in the future.
One of:
Field
Type
Description
meter
string
Device identifier, unique within the tenant. Required UNLESS the item declares `profile` and that profile derives the identifier from the payload itself — today only `wmbus_oms` (#101), whose telegram carries the device address in its unencrypted link layer. A supplied `meter` always wins, so a gateway configured with an operator's own names keeps using them.
profile
string
Optional explicit model declaration (catalog key). Unknown meters are provisioned onto this profile; unconfirmed provisioning defaults are upgraded to it; a mismatch with a user-confirmed assignment is rejected with 422.
payload*
oneOf
Either the profile-schema fields as an object (e.g. IZAR: value (register reading, number) + status (list of flag strings)), or — for a workspace profile that declares a binary decoder (#100) — ONE ENCODED STRING holding the raw device frame, hex or base64 as the profile states. The decoder turns that string into exactly the field object the first form would have carried, so everything downstream is identical; a frame that does not match the declared layout is rejected with 422 naming the field it fell short for.
One of: object
One of: string
unit
string
kwh | wh | m3 | l
Unit of the payload's measurement fields
recorded_at*
string
Device-side measurement time (ISO 8601). Must not lie more than 5 minutes in the future.
items refused per item: provisioning cap or unconfigured meter
results
array
Field
Type
Description
meter
string
kind
string
consumption | saldo
recorded_at
string
reason
string
Present only on rejections that carry information beyond their status — currently the telegram ones (#101). A cap rejection has nothing to add: its status already says whether to slow down or to upgrade.
rejected_meter_retired (#109): the meter has been removed or archived and no longer accepts device readings. This is not a cap and retrying changes nothing — the meter is declared physically gone, and appending to its series would extend a register past the device that produced it. If the state is simply out of date, put the meter back in service in the meter settings and the next report ingests. `reason` names the state it is in. rejected_unconfigured (#44, #101): the meter cannot convert this item yet — a pulse report without a configured pulse weight, or an encrypted wM-Bus telegram with no key stored on the meter; configure the meter and resend. The meter itself IS provisioned, so it can be configured. rejected_undecodable (#101): the payload is well-formed but could not be read — a wrong wM-Bus key, an unsupported security mode, or a telegram carrying no current register. `reason` says which. A structurally invalid payload is a 422 for the whole batch instead. rejected_provision_limit (#13): too many NEW meters this hour; existing meters are unaffected, retry later. rejected_plan_limit (#32): the workspace is at its plan's meter limit; an owner has to upgrade. Both reject only the creation of a new meter — readings for meters that already exist always ingest.
200
Nothing new — every entry was a duplicate (retry)
Field
Type
Description
accepted
integer
newly stored readings
duplicates
integer
retried reports, ignored
rejected
integer
items refused per item: provisioning cap or unconfigured meter
results
array
Field
Type
Description
meter
string
kind
string
consumption | saldo
recorded_at
string
reason
string
Present only on rejections that carry information beyond their status — currently the telegram ones (#101). A cap rejection has nothing to add: its status already says whether to slow down or to upgrade.
rejected_meter_retired (#109): the meter has been removed or archived and no longer accepts device readings. This is not a cap and retrying changes nothing — the meter is declared physically gone, and appending to its series would extend a register past the device that produced it. If the state is simply out of date, put the meter back in service in the meter settings and the next report ingests. `reason` names the state it is in. rejected_unconfigured (#44, #101): the meter cannot convert this item yet — a pulse report without a configured pulse weight, or an encrypted wM-Bus telegram with no key stored on the meter; configure the meter and resend. The meter itself IS provisioned, so it can be configured. rejected_undecodable (#101): the payload is well-formed but could not be read — a wrong wM-Bus key, an unsupported security mode, or a telegram carrying no current register. `reason` says which. A structurally invalid payload is a 422 for the whole batch instead. rejected_provision_limit (#13): too many NEW meters this hour; existing meters are unaffected, retry later. rejected_plan_limit (#32): the workspace is at its plan's meter limit; an owner has to upgrade. Both reject only the creation of a new meter — readings for meters that already exist always ingest.
401
Missing, unknown, revoked or expired token
Field
Type
Description
message
string
403
Token lacks the `data:report` scope
Field
Type
Description
message
string
422
Validation failed — structural or against the meter's profile schema. `errors` keys name the offending field (e.g. `readings.3.payload.value`); the whole batch was rejected, nothing was stored.
Field
Type
Description
message
string
errors
object
429
Per-token rate limit exceeded
Field
Type
Description
message
string
POST/api/v1/uplink/{provider}
Accept a LoRaWAN uplink from a network server
Webhook endpoint for The Things Stack, ChirpStack and Helium (#99). The network server posts its own uplink envelope; the device EUI identifies the meter (auto-provisioned under the usual caps if unknown), and the frame is decoded by the meter device profile binary decoder (#100) before becoming an ordinary reading.
A REFUSED UPLINK STILL ANSWERS 2xx, with the reason in `status`. Network servers treat a non-2xx as a delivery failure and retry for hours, and a retry cannot fix a device whose profile has no decoder or a frame that does not match the declared layout. The one exception is a body that cannot be read at all, which is a misconfigured integration and answers 422.
Radio metadata (RSSI, SNR, spreading factor, gateway, frame counter) is recorded per uplink even when no reading results, so a device that has gone quiet can still be shown to be transmitting.
Query parameters
Name
Type
Description
provider
string
ttn | chirpstack | helium
Which network server format the body is in
Request body
object— The network server own webhook body, unmodified. Each provider is parsed by its own reader; the shapes are not interchangeable.
`created` — decoded and stored. `duplicate` — already had this reading (a webhook retry). `accepted_no_payload` — a frame with no application payload (a join, an empty confirmation); recorded, no reading. `rejected_no_decoder` — the meter device profile declares no binary decoder yet; the frame is recorded so the device is visible while somebody configures it. `rejected_undecodable` — the frame does not match the declared layout. `rejected_provision_limit` / `rejected_plan_limit` — a new device EUI hit the hourly provisioning guard or the plan meter cap. `rejected_meter_retired` (#109) — the meter has been removed or archived; the uplink is still recorded, so the device stays visible as transmitting, but no reading is stored.
meter
string
The device EUI, lower-case hex
frame_count
integer, null
LoRaWAN uplink frame counter (fCnt), when reported
missed_frames
integer, null
Uplinks lost since the previous frame. NULL means not computable — a first uplink, a counter reset, or a wrapped 16-bit counter — which is not the same as zero.
200
The uplink was accepted for the record but produced no reading — see `status`. Deliberately not an error, so the network server does not retry.
`created` — decoded and stored. `duplicate` — already had this reading (a webhook retry). `accepted_no_payload` — a frame with no application payload (a join, an empty confirmation); recorded, no reading. `rejected_no_decoder` — the meter device profile declares no binary decoder yet; the frame is recorded so the device is visible while somebody configures it. `rejected_undecodable` — the frame does not match the declared layout. `rejected_provision_limit` / `rejected_plan_limit` — a new device EUI hit the hourly provisioning guard or the plan meter cap. `rejected_meter_retired` (#109) — the meter has been removed or archived; the uplink is still recorded, so the device stays visible as transmitting, but no reading is stored.
meter
string
The device EUI, lower-case hex
frame_count
integer, null
LoRaWAN uplink frame counter (fCnt), when reported
missed_frames
integer, null
Uplinks lost since the previous frame. NULL means not computable — a first uplink, a counter reset, or a wrapped 16-bit counter — which is not the same as zero.
401
Missing or invalid API token
—
403
The token lacks the data:report scope
—
422
Unknown provider, or a body that is not that provider format — a misconfigured integration rather than a device problem.
—
POST/api/v1/webhooks/{endpoint}
Accept a foreign JSON payload and map it onto readings
Generic webhook ingest (#102, ADR 0038). The endpoint accepts whatever JSON the sending system already produces and maps it onto readings using a declarative, per-endpoint field mapping configured in Settings → Webhook ingest. Nothing about the sending system has to change.
AUTHENTICATION IS PER ENDPOINT, not per API token: the URL carries a non-secret endpoint id and the token travels in `Authorization: Bearer` (or `X-Webhook-Token`). An unknown endpoint, a wrong token and a disabled endpoint are all answered 401 with the same body, so a probe cannot learn which endpoints exist.
AFTER MAPPING, THE ORDINARY INGEST PATH RUNS — the same one `/reportData` uses. Auto-provisioning, plan caps, device-profile validation, `(meter, kind, recorded_at)` idempotency, data quality and the status layer all apply unchanged.
`recorded_at` ALWAYS COMES FROM THE PAYLOAD and is never taken from the server clock. That is what makes a retried delivery a duplicate rather than a second reading, so an item without a readable timestamp is rejected rather than stamped.
THE STATUS CODE DISTINGUISHES TWO KINDS OF WRONGNESS. A mapping that does not fit the body at all is 422, because it means the endpoint is misconfigured and the integrator needs to be told. One unmappable item among many is 200 with a per-item status, because a delivery is routinely a batch from a gateway and one bad reading must not cost the rest of a site theirs.
Query parameters
Name
Type
Description
endpoint
string
The endpoint's public id, shown as part of its URL in Settings → Webhook ingest. Not a secret, but not enumerable either — the token is the credential.
`rejected_unmappable` is this endpoint's own: the mapping could not read a required field out of that reading. The others are the ordinary ingest outcomes and mean the same as on `/reportData` — including `rejected_meter_retired` (#109), which means the meter has been removed or archived and no longer accepts device readings.
reason
string
Present where the status alone does not say enough
meter
string
kind
string
recorded_at
string
401
Unknown endpoint, wrong token, or the endpoint is switched off. These are deliberately indistinguishable.
—
422
The body is not JSON, or this endpoint's mapping does not fit it — a configuration problem rather than a device problem. Also returned when a mapped reading violates its meter's device profile, in which case nothing in the batch was written.
—
429
Per-IP rate limit exceeded
—
GET/api/v1/meters
List the tenant's meters
Meter inventory of the token's tenant, ordered by identifier.
Requires the `data:read` scope. Use the returned `id` values to
filter `/readings`.
Responses
Status
Description
Body
200
Meter list
Field
Type
Description
data
array
Field
Type
Description
id
integer
meter_identifier
string
name
string, null
unit
string, null
profile
object, null
Device profile (#41) — which meter model's data format this meter reports. `needs_review: true` means the profile was defaulted at auto-provisioning and has not been confirmed by a user yet.
Field
Type
Description
key
string
version
integer
name
string
needs_review
boolean
group
object, null
null = the "Ungrouped meters" bucket
Field
Type
Description
id
integer
name
string
lifecycle
object
Where this meter is in its life as an asset (#109). `planned`, `active`, `inactive`, `removed`, `archived`. Only an `operational` meter is watched by alerting, counted against the plan's meter limit, and open to device readings; a `removed` or `archived` meter answers `rejected_meter_retired` on every ingestion path. Readings already stored are unaffected in every state and stay in aggregates, queries and exports. Read `operational` rather than comparing `state` yourself: which states count as in-operation is this platform's rule, and a client hard-coding one would disagree with us the day another state is added.
Field
Type
Description
state
string
planned | active | inactive | removed | archived
operational
boolean
changed_at
string, null
null = never changed since the meter was created
status
object, null
Latest device-reported status (#45), normalized to the canonical vocabulary. Empty `statuses` = the device reports all-clear; null = this meter never reported status. History (including raw provider flags): `/statusReports`.
Field
Type
Description
statuses
array
stringleak | burst | dry | reverse | tamper | low_battery | error— Canonical device-status vocabulary (#45): every device profile maps its raw flags onto this set. Raw flags a profile cannot interpret map to `error` (surfaced, never dropped).
reported_at
string
401
Missing, unknown, revoked or expired token
Field
Type
Description
message
string
403
Token lacks the scope required by this endpoint
Field
Type
Description
message
string
429
Per-token rate limit exceeded
Field
Type
Description
message
string
POST/api/v1/meters
Create a meter
Creates a meter without waiting for it to report (#129). Until
now auto-provisioning was the only creation path; this is the
second, and it honours the same rules.
Requires the `masterdata:write` scope AND a token whose creator
still holds the `groups.manage` permission in this workspace —
a token can never do more than the person who minted it.
- The plan's meter cap applies (402 with
`reason: rejected_plan_limit`). The hourly auto-provisioning
cap deliberately does not: it exists to bound a fleet
inventing identifiers, not a deliberate import.
- A device profile is always assigned. Omitting one gets the
generic default with `needs_review: true`, exactly as
auto-provisioning does, so the meter appears in the same
review list.
- Setting `unit`, `rollover_max`, `pulse_weight` or
`pulse_unit` returns a `warning` naming
`mymeter:backfill-reading-quality` — those values change how
ALREADY-STORED readings are interpreted, and nothing
recomputes them by itself.
Request body
Field
Type
Description
meter_identifier
string
Unique within the workspace. Required on create.
name
string, null
unit
string, null
The unit the DEVICE reports in.
display_unit
string, null
Preferred unit for SHOWING this meter's numbers (#88). Must
be in the same family as `unit`; null means the family's
canonical unit. PATCH only.
group_id
integer, null
rollover_max
number, null
Register wrap value, in the declared unit (#29).
expected_interval_minutes
integer, null
Reporting cadence; drives gap detection (#29).
pulse_weight
number, null
1 pulse = pulse_weight x pulse_unit (#44). Set with pulse_unit or not at all.
pulse_unit
string, null
Responses
Status
Description
Body
201
Meter created
Field
Type
Description
data
object
Field
Type
Description
id
integer
meter_identifier
string
name
string, null
unit
string, null
profile
object, null
Device profile (#41) — which meter model's data format this meter reports. `needs_review: true` means the profile was defaulted at auto-provisioning and has not been confirmed by a user yet.
Field
Type
Description
key
string
version
integer
name
string
needs_review
boolean
group
object, null
null = the "Ungrouped meters" bucket
Field
Type
Description
id
integer
name
string
lifecycle
object
Where this meter is in its life as an asset (#109). `planned`, `active`, `inactive`, `removed`, `archived`. Only an `operational` meter is watched by alerting, counted against the plan's meter limit, and open to device readings; a `removed` or `archived` meter answers `rejected_meter_retired` on every ingestion path. Readings already stored are unaffected in every state and stay in aggregates, queries and exports. Read `operational` rather than comparing `state` yourself: which states count as in-operation is this platform's rule, and a client hard-coding one would disagree with us the day another state is added.
Field
Type
Description
state
string
planned | active | inactive | removed | archived
operational
boolean
changed_at
string, null
null = never changed since the meter was created
status
object, null
Latest device-reported status (#45), normalized to the canonical vocabulary. Empty `statuses` = the device reports all-clear; null = this meter never reported status. History (including raw provider flags): `/statusReports`.
Field
Type
Description
statuses
array
stringleak | burst | dry | reverse | tamper | low_battery | error— Canonical device-status vocabulary (#45): every device profile maps its raw flags onto this set. Raw flags a profile cannot interpret map to `error` (surfaced, never dropped).
reported_at
string
warning
object, null
Present only when the request changed a value that alters
how ALREADY-STORED readings are interpreted. Nothing
recomputes them by itself — run
`mymeter:backfill-reading-quality`.
Field
Type
Description
message
string
changed
array
string
401
Missing, unknown, revoked or expired token
Field
Type
Description
message
string
403
Token lacks the scope required by this endpoint
Field
Type
Description
message
string
422
The request was well-formed JSON but failed validation. `errors`
is keyed by field name.
Field
Type
Description
message
string
errors
object
429
Per-token rate limit exceeded
Field
Type
Description
message
string
PATCH/api/v1/meters/{meter}
Change a meter
Partial update (#129); only the fields sent are changed. Scope
`masterdata:write` plus the creator's `groups.manage`.
`display_unit` must belong to the meter's own unit family —
showing a kWh meter in m³ is a category error, not a preference
(#88). Interpretation-changing fields return the same backfill
`warning` as on create.
Request body
Field
Type
Description
meter_identifier
string
Unique within the workspace. Required on create.
name
string, null
unit
string, null
The unit the DEVICE reports in.
display_unit
string, null
Preferred unit for SHOWING this meter's numbers (#88). Must
be in the same family as `unit`; null means the family's
canonical unit. PATCH only.
group_id
integer, null
rollover_max
number, null
Register wrap value, in the declared unit (#29).
expected_interval_minutes
integer, null
Reporting cadence; drives gap detection (#29).
pulse_weight
number, null
1 pulse = pulse_weight x pulse_unit (#44). Set with pulse_unit or not at all.
pulse_unit
string, null
Responses
Status
Description
Body
200
Meter updated
Field
Type
Description
data
object
Field
Type
Description
id
integer
meter_identifier
string
name
string, null
unit
string, null
profile
object, null
Device profile (#41) — which meter model's data format this meter reports. `needs_review: true` means the profile was defaulted at auto-provisioning and has not been confirmed by a user yet.
Field
Type
Description
key
string
version
integer
name
string
needs_review
boolean
group
object, null
null = the "Ungrouped meters" bucket
Field
Type
Description
id
integer
name
string
lifecycle
object
Where this meter is in its life as an asset (#109). `planned`, `active`, `inactive`, `removed`, `archived`. Only an `operational` meter is watched by alerting, counted against the plan's meter limit, and open to device readings; a `removed` or `archived` meter answers `rejected_meter_retired` on every ingestion path. Readings already stored are unaffected in every state and stay in aggregates, queries and exports. Read `operational` rather than comparing `state` yourself: which states count as in-operation is this platform's rule, and a client hard-coding one would disagree with us the day another state is added.
Field
Type
Description
state
string
planned | active | inactive | removed | archived
operational
boolean
changed_at
string, null
null = never changed since the meter was created
status
object, null
Latest device-reported status (#45), normalized to the canonical vocabulary. Empty `statuses` = the device reports all-clear; null = this meter never reported status. History (including raw provider flags): `/statusReports`.
Field
Type
Description
statuses
array
stringleak | burst | dry | reverse | tamper | low_battery | error— Canonical device-status vocabulary (#45): every device profile maps its raw flags onto this set. Raw flags a profile cannot interpret map to `error` (surfaced, never dropped).
reported_at
string
warning
object, null
Present only when the request changed a value that alters
how ALREADY-STORED readings are interpreted. Nothing
recomputes them by itself — run
`mymeter:backfill-reading-quality`.
Field
Type
Description
message
string
changed
array
string
401
Missing, unknown, revoked or expired token
Field
Type
Description
message
string
403
Token lacks the scope required by this endpoint
Field
Type
Description
message
string
422
The request was well-formed JSON but failed validation. `errors`
is keyed by field name.
Field
Type
Description
message
string
errors
object
429
Per-token rate limit exceeded
Field
Type
Description
message
string
DELETE/api/v1/meters/{meter}
Delete a meter and its readings
Deletes the meter and everything hanging off it, readings
included (#129).
REQUIRES AN EXPLICIT CONFIRMATION: send
`confirm_meter_identifier` matching the meter's own identifier,
so a mistyped id deletes nothing rather than the wrong meter.
Readings are unreconstructible — devices do not retransmit — so
this is the one call whose mistake cannot be undone by calling
again differently. The response names how many readings went
with it.
Request body
Field
Type
Description
confirm_meter_identifier*
string
Must equal the meter's own identifier.
Responses
Status
Description
Body
200
Meter deleted
Field
Type
Description
data
object
Field
Type
Description
deleted
boolean
id
integer
meter_identifier
string
readings_deleted
integer
How many readings were deleted with it.
readings_from
string, null
Oldest and newest reading destroyed (#109), so a reconciliation afterwards can tell which window left the system. Both null when the meter held no readings — an empty period, not a period of zero length.
readings_to
string, null
401
Missing, unknown, revoked or expired token
Field
Type
Description
message
string
403
Token lacks the scope required by this endpoint
Field
Type
Description
message
string
422
The request was well-formed JSON but failed validation. `errors`
is keyed by field name.
Field
Type
Description
message
string
errors
object
429
Per-token rate limit exceeded
Field
Type
Description
message
string
GET/api/v1/meterGroups
List meter groups (tree or subtree)
The group tree of the token's tenant, ordered by materialised
path. Requires `data:read`. Pass `group_id` to get that group's
SUBTREE (path-prefix semantics, #12).
Flat with `parent_id` and `path` rather than nested: `path`
makes the hierarchy reconstructible in one pass, and a nested
document would need a depth limit this API does not.
Query parameters
Name
Type
Description
group_id
integer
Responses
Status
Description
Body
200
Group list
Field
Type
Description
data
array
Field
Type
Description
id
integer
name
string
parent_id
integer, null
path
string
Materialised path, e.g. "/1/4/" (#12). A subtree is a path
prefix, which is what makes the flat list reconstructible.
meter_count
integer, null
Meters filed directly in this group (not the subtree).
401
Missing, unknown, revoked or expired token
Field
Type
Description
message
string
403
Token lacks the scope required by this endpoint
Field
Type
Description
message
string
429
Per-token rate limit exceeded
Field
Type
Description
message
string
POST/api/v1/meterGroups
Create a meter group
Requires `masterdata:write` plus the creator's `groups.manage`.
Names must be unique among siblings; 409 if they are not.
Request body
Field
Type
Description
name*
string
parent_id
integer, null
Omit or null for a root group.
Responses
Status
Description
Body
201
Group created
Field
Type
Description
data
object
Field
Type
Description
id
integer
name
string
parent_id
integer, null
path
string
Materialised path, e.g. "/1/4/" (#12). A subtree is a path
prefix, which is what makes the flat list reconstructible.
meter_count
integer, null
Meters filed directly in this group (not the subtree).
401
Missing, unknown, revoked or expired token
Field
Type
Description
message
string
403
Token lacks the scope required by this endpoint
Field
Type
Description
message
string
409
The request is valid but conflicts with the workspace's current
state — a duplicate sibling name, a move into the group's own
subtree, or a delete of a group that still contains something.
Fixable by the caller, hence 409 rather than 422.
Field
Type
Description
message
string
422
The request was well-formed JSON but failed validation. `errors`
is keyed by field name.
Field
Type
Description
message
string
errors
object
429
Per-token rate limit exceeded
Field
Type
Description
message
string
PATCH/api/v1/meterGroups/{group}
Rename or move a meter group
Requires `masterdata:write` plus the creator's `groups.manage`.
OMITTING `parent_id` leaves the group where it is; SENDING null
makes it a root. The two differ on purpose, so a rename does not
also re-parent. Moving a group into its own subtree is refused
(409), as is a name that clashes with a sibling. The whole
subtree's paths are rewritten in one statement.
Request body
Field
Type
Description
name
string
parent_id
integer, null
Responses
Status
Description
Body
200
Group updated
Field
Type
Description
data
object
Field
Type
Description
id
integer
name
string
parent_id
integer, null
path
string
Materialised path, e.g. "/1/4/" (#12). A subtree is a path
prefix, which is what makes the flat list reconstructible.
meter_count
integer, null
Meters filed directly in this group (not the subtree).
401
Missing, unknown, revoked or expired token
Field
Type
Description
message
string
403
Token lacks the scope required by this endpoint
Field
Type
Description
message
string
409
The request is valid but conflicts with the workspace's current
state — a duplicate sibling name, a move into the group's own
subtree, or a delete of a group that still contains something.
Fixable by the caller, hence 409 rather than 422.
Field
Type
Description
message
string
422
The request was well-formed JSON but failed validation. `errors`
is keyed by field name.
Field
Type
Description
message
string
errors
object
429
Per-token rate limit exceeded
Field
Type
Description
message
string
DELETE/api/v1/meterGroups/{group}
Delete an empty meter group
Requires `masterdata:write` plus the creator's `groups.manage`.
A group that still contains subgroups or meters is refused with
409 — move or delete them first. Nothing cascades: a group
delete must never take meters with it.
Responses
Status
Description
Body
200
Group deleted
Field
Type
Description
data
object
Field
Type
Description
deleted
boolean
id
integer
401
Missing, unknown, revoked or expired token
Field
Type
Description
message
string
403
Token lacks the scope required by this endpoint
Field
Type
Description
message
string
409
The request is valid but conflicts with the workspace's current
state — a duplicate sibling name, a move into the group's own
subtree, or a delete of a group that still contains something.
Fixable by the caller, hence 409 rather than 422.
Field
Type
Description
message
string
429
Per-token rate limit exceeded
Field
Type
Description
message
string
GET/api/v1/readings
Query readings (raw or aggregated)
Readings of the token's tenant within a time range, optionally
filtered to one meter or one group subtree and/or one kind.
Requires the `data:read` scope.
- Defaults: `to` = now, `from` = `to` − 7 days; the range is
capped at 366 days.
- `aggregate=raw` returns individual readings (oldest first),
including canonical values and quality flags (#29).
- `aggregate=hourly|daily` returns one bucket per
(bucket, meter, kind) with `sum`, `min`, `max`, `avg` and
`reading_count`, computed over CANONICAL values (kwh / m3,
#29) — readings without a canonical value (unit mismatch,
not yet backfilled) are excluded from buckets. For
`consumption` the meaningful figure is usually `sum`; for
`saldo` (a counter state) use `min`/`max`/`avg` — summing
counter states is meaningless.
- `tz=<IANA zone>` buckets in local wall time — daily buckets
on DST-change days correctly span 23/25 hours; `bucket_start`
is then local time. Raw rows gain `recorded_at_local`.
Storage and defaults stay UTC.
- `derive=consumption` (requires `meter_id`, no `aggregate`)
returns the consumption series derived from consecutive
SALDO readings, bridging counter rollovers and recorded
meter exchanges (#29). Intervals longer than the meter's
expected cadence are marked `gap: true`;
`interpolate=linear` (opt-in) splits such deltas into
cadence slots, each marked `interpolated: true`. Unknown
intervals (exchange without baselines, unexplained decrease)
have `consumption: null` — values are never invented.
Query parameters
Name
Type
Description
meter_id
integer
Filter to one meter (mutually exclusive with group_id)
group_id
integer
Filter to a group INCLUDING its subtree
kind
string
consumption | saldo
from
string
to
string
aggregate
string
raw | hourly | daily
default: 'raw'
tz
string
IANA timezone for bucketing/presentation (#29), e.g. Europe/Vienna
derive
string
consumption
Derive consumption from saldo readings of one meter (#29)
interpolate
string
linear
Only with derive: split gap deltas into cadence slots, marked interpolated
page
integer
default: 1
per_page
integer
default: 100
Responses
Status
Description
Body
200
Readings page
Field
Type
Description
aggregate
string
raw | hourly | daily
from
string
to
string
data
array
One of:
Field
Type
Description
meter_id
integer
meter_identifier
string
kind
string
consumption | saldo
value
number
Raw value exactly as reported
unit
string, null
canonical_value
number, null
Value in the family's canonical unit (#29); null = unit mismatch or not backfilled
Only present when tz was given — recorded_at in that zone
One of:
Field
Type
Description
bucket_start
string
Bucket start, "YYYY-MM-DD HH:MM:SS" in UTC
meter_id
integer
kind
string
consumption | saldo
reading_count
integer
sum
number
min
number
max
number
avg
number
One of:
Field
Type
Description
period_start
string
period_end
string
consumption
number, null
Canonical units; null = unknown interval
unit
string, null
kwh | m3 | null
gap
boolean
Interval exceeds the meter's expected cadence
rollover
boolean
Delta bridges a counter rollover
exchange
boolean
Delta bridges a recorded meter exchange
unknown
boolean
True when consumption could not be derived
interpolated
boolean
Row was synthesized by interpolate=linear
meta
object
Field
Type
Description
current_page
integer
per_page
integer
total
integer
last_page
integer
401
Missing, unknown, revoked or expired token
Field
Type
Description
message
string
403
Token lacks the scope required by this endpoint
Field
Type
Description
message
string
422
Validation failed (unknown meter/group, bad range, …)
Field
Type
Description
message
string
429
Per-token rate limit exceeded
Field
Type
Description
message
string
GET/api/v1/statusReports
Query device-status history
Device-status reports of the token's tenant, newest first.
Requires the `data:read` scope.
Each row is one status the device itself reported (#45): the
provider's raw flags verbatim plus the normalized canonical
codes every profile maps onto. An empty `statuses` list means
the device reported all-clear — a leak appearing and later
clearing is therefore two traceable rows. Device status is
distinct from the platform-computed data-quality flags on
`/readings` (#29); both surface as metering issues in the UI.
Query parameters
Name
Type
Description
meter_id
integer
Filter to one meter
from
string
to
string
page
integer
default: 1
per_page
integer
default: 100
Responses
Status
Description
Body
200
Status report page
Field
Type
Description
data
array
Field
Type
Description
meter_id
integer
meter_identifier
string
raw_flags
array
Provider flags verbatim, exactly as reported
string
statuses
array
Canonical codes; empty = device reported all-clear
stringleak | burst | dry | reverse | tamper | low_battery | error— Canonical device-status vocabulary (#45): every device profile maps its raw flags onto this set. Raw flags a profile cannot interpret map to `error` (surfaced, never dropped).
reported_at
string
meta
object
Field
Type
Description
current_page
integer
per_page
integer
total
integer
last_page
integer
401
Missing, unknown, revoked or expired token
Field
Type
Description
message
string
403
Token lacks the scope required by this endpoint
Field
Type
Description
message
string
422
Validation failed (unknown meter, …)
Field
Type
Description
message
string
429
Per-token rate limit exceeded
Field
Type
Description
message
string
Error reference
Errors are JSON objects with a "message" field. The dedicated statuses:
Case
Description
Unauthenticated
Missing, unknown, revoked or expired token
RateLimited
Per-token rate limit exceeded
ValidationError
The request was well-formed JSON but failed validation. `errors`
is keyed by field name.
GroupConflict
The request is valid but conflicts with the workspace's current
state — a duplicate sibling name, a move into the group's own
subtree, or a delete of a group that still contains something.
Fixable by the caller, hence 409 rather than 422.
MissingScope
Token lacks the scope required by this endpoint
ValidationFailed (422)
"message" plus an "errors" map of field name to messages — fix the request and retry.