MyMeter — API reference v1

BEN MyMeter API

Authentication & limits

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

StatusDescriptionBody
200 API reachable and token valid
FieldTypeDescription
status string
ok | degraded
application string
time string
scheduler object
FieldTypeDescription
last_run_at string, null
ever_ran boolean
stale boolean
401 Missing, unknown, revoked or expired token
FieldTypeDescription
message string
429 Per-token rate limit exceeded
FieldTypeDescription
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

FieldTypeDescription
readings * array
  • One of:
    FieldTypeDescription
    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:
    FieldTypeDescription
    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.

Example: hourly-consumption

{
    "readings": [
        {
            "meter": "MTR-00012345",
            "kind": "consumption",
            "value": 1.25,
            "unit": "kwh",
            "recorded_at": "2026-08-15T10:00:00Z"
        }
    ]
}

Example: saldo

{
    "readings": [
        {
            "meter": "MTR-00012345",
            "kind": "saldo",
            "value": 4521.75,
            "unit": "kwh",
            "recorded_at": "2026-08-15T10:00:00Z"
        }
    ]
}

Example: izar-register-with-status

{
    "readings": [
        {
            "meter": "IZAR-88231",
            "profile": "izar",
            "unit": "m3",
            "payload": {
                "value": 4521.5,
                "status": [
                    "leakage",
                    "battery_low"
                ]
            },
            "recorded_at": "2026-08-15T10:00:00Z"
        }
    ]
}

Example: wmbus-oms-telegram

{
    "readings": [
        {
            "profile": "wmbus_oms",
            "payload": {
                "telegram": "1444A5117856341233077A2A0000000C1345230100"
            },
            "recorded_at": "2026-08-15T10:00:00Z"
        }
    ]
}

Example: pulse-counter

{
    "readings": [
        {
            "meter": "PULSE-7",
            "profile": "pulse_counter",
            "payload": {
                "pulses": 12845
            },
            "recorded_at": "2026-08-15T10:00:00Z"
        }
    ]
}

Responses

StatusDescriptionBody
201 At least one reading stored
FieldTypeDescription
accepted integer newly stored readings
duplicates integer retried reports, ignored
rejected integer items refused per item: provisioning cap or unconfigured meter
results array
FieldTypeDescription
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.
status string
created | duplicate | rejected_provision_limit | rejected_plan_limit | rejected_unconfigured | rejected_undecodable | rejected_meter_retired
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)
FieldTypeDescription
accepted integer newly stored readings
duplicates integer retried reports, ignored
rejected integer items refused per item: provisioning cap or unconfigured meter
results array
FieldTypeDescription
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.
status string
created | duplicate | rejected_provision_limit | rejected_plan_limit | rejected_unconfigured | rejected_undecodable | rejected_meter_retired
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
FieldTypeDescription
message string
403 Token lacks the `data:report` scope
FieldTypeDescription
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.
FieldTypeDescription
message string
errors object
429 Per-token rate limit exceeded
FieldTypeDescription
message string

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

NameTypeDescription
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.

Request body

Example: nested

{
    "site": "acme-hq",
    "readings": [
        {
            "sensor": "WATER-1",
            "at": "2026-08-28T10:00:00Z",
            "reading": 1240.25
        },
        {
            "sensor": "WATER-2",
            "at": "2026-08-28T10:00:00Z",
            "reading": 88.5
        }
    ]
}

Example: single

{
    "device_id": "HEAT-3",
    "timestamp": 1787911200,
    "v": 41234.5999999999985448084771633148193359375
}

Responses

StatusDescriptionBody
200 The delivery was read. Individual readings may still have been refused — see `results`.
FieldTypeDescription
accepted integer Readings created or recognised as already stored
rejected integer Readings refused, each with its reason in `results`
message string Present only when the whole delivery was refused; names what about the body or the mapping did not fit.
results array One entry per reading in the delivery, in the order the delivery carried them.
FieldTypeDescription
index integer Position in the delivery, so a refusal can be traced back to the reading in your own payload.
status string
created | duplicate | rejected_unmappable | rejected_provision_limit | rejected_plan_limit | rejected_unconfigured | rejected_undecodable | rejected_meter_retired
`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

StatusDescriptionBody
200 Meter list
FieldTypeDescription
data array
FieldTypeDescription
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.
FieldTypeDescription
key string
version integer
name string
needs_review boolean
group object, null null = the "Ungrouped meters" bucket
FieldTypeDescription
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.
FieldTypeDescription
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`.
FieldTypeDescription
statuses array string leak | 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
FieldTypeDescription
message string
403 Token lacks the scope required by this endpoint
FieldTypeDescription
message string
429 Per-token rate limit exceeded
FieldTypeDescription
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

FieldTypeDescription
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

StatusDescriptionBody
201 Meter created
FieldTypeDescription
data object
FieldTypeDescription
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.
FieldTypeDescription
key string
version integer
name string
needs_review boolean
group object, null null = the "Ungrouped meters" bucket
FieldTypeDescription
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.
FieldTypeDescription
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`.
FieldTypeDescription
statuses array string leak | 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`.
FieldTypeDescription
message string
changed array string
401 Missing, unknown, revoked or expired token
FieldTypeDescription
message string
403 Token lacks the scope required by this endpoint
FieldTypeDescription
message string
422 The request was well-formed JSON but failed validation. `errors` is keyed by field name.
FieldTypeDescription
message string
errors object
429 Per-token rate limit exceeded
FieldTypeDescription
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

FieldTypeDescription
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

StatusDescriptionBody
200 Meter updated
FieldTypeDescription
data object
FieldTypeDescription
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.
FieldTypeDescription
key string
version integer
name string
needs_review boolean
group object, null null = the "Ungrouped meters" bucket
FieldTypeDescription
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.
FieldTypeDescription
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`.
FieldTypeDescription
statuses array string leak | 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`.
FieldTypeDescription
message string
changed array string
401 Missing, unknown, revoked or expired token
FieldTypeDescription
message string
403 Token lacks the scope required by this endpoint
FieldTypeDescription
message string
422 The request was well-formed JSON but failed validation. `errors` is keyed by field name.
FieldTypeDescription
message string
errors object
429 Per-token rate limit exceeded
FieldTypeDescription
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

FieldTypeDescription
confirm_meter_identifier * string Must equal the meter's own identifier.

Responses

StatusDescriptionBody
200 Meter deleted
FieldTypeDescription
data object
FieldTypeDescription
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
FieldTypeDescription
message string
403 Token lacks the scope required by this endpoint
FieldTypeDescription
message string
422 The request was well-formed JSON but failed validation. `errors` is keyed by field name.
FieldTypeDescription
message string
errors object
429 Per-token rate limit exceeded
FieldTypeDescription
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

NameTypeDescription
group_id integer

Responses

StatusDescriptionBody
200 Group list
FieldTypeDescription
data array
FieldTypeDescription
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
FieldTypeDescription
message string
403 Token lacks the scope required by this endpoint
FieldTypeDescription
message string
429 Per-token rate limit exceeded
FieldTypeDescription
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

FieldTypeDescription
name * string
parent_id integer, null Omit or null for a root group.

Responses

StatusDescriptionBody
201 Group created
FieldTypeDescription
data object
FieldTypeDescription
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
FieldTypeDescription
message string
403 Token lacks the scope required by this endpoint
FieldTypeDescription
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.
FieldTypeDescription
message string
422 The request was well-formed JSON but failed validation. `errors` is keyed by field name.
FieldTypeDescription
message string
errors object
429 Per-token rate limit exceeded
FieldTypeDescription
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

FieldTypeDescription
name string
parent_id integer, null

Responses

StatusDescriptionBody
200 Group updated
FieldTypeDescription
data object
FieldTypeDescription
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
FieldTypeDescription
message string
403 Token lacks the scope required by this endpoint
FieldTypeDescription
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.
FieldTypeDescription
message string
422 The request was well-formed JSON but failed validation. `errors` is keyed by field name.
FieldTypeDescription
message string
errors object
429 Per-token rate limit exceeded
FieldTypeDescription
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

StatusDescriptionBody
200 Group deleted
FieldTypeDescription
data object
FieldTypeDescription
deleted boolean
id integer
401 Missing, unknown, revoked or expired token
FieldTypeDescription
message string
403 Token lacks the scope required by this endpoint
FieldTypeDescription
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.
FieldTypeDescription
message string
429 Per-token rate limit exceeded
FieldTypeDescription
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

NameTypeDescription
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

StatusDescriptionBody
200 Readings page
FieldTypeDescription
aggregate string
raw | hourly | daily
from string
to string
data array
  • One of:
    FieldTypeDescription
    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
    canonical_unit string, null
    kwh | m3 | null
    quality_flags array, null Data quality flags (#29, see ADR 0007); null = clean string unit_mismatch | negative_consumption | implausible_rate | saldo_mismatch | saldo_decrease | rollover_wrap | meter_exchange
    recorded_at string
    recorded_at_local string Only present when tz was given — recorded_at in that zone
  • One of:
    FieldTypeDescription
    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:
    FieldTypeDescription
    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
FieldTypeDescription
current_page integer
per_page integer
total integer
last_page integer
401 Missing, unknown, revoked or expired token
FieldTypeDescription
message string
403 Token lacks the scope required by this endpoint
FieldTypeDescription
message string
422 Validation failed (unknown meter/group, bad range, …)
FieldTypeDescription
message string
429 Per-token rate limit exceeded
FieldTypeDescription
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

NameTypeDescription
meter_id integer Filter to one meter
from string
to string
page integer
default: 1
per_page integer
default: 100

Responses

StatusDescriptionBody
200 Status report page
FieldTypeDescription
data array
FieldTypeDescription
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 string leak | 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
FieldTypeDescription
current_page integer
per_page integer
total integer
last_page integer
401 Missing, unknown, revoked or expired token
FieldTypeDescription
message string
403 Token lacks the scope required by this endpoint
FieldTypeDescription
message string
422 Validation failed (unknown meter, …)
FieldTypeDescription
message string
429 Per-token rate limit exceeded
FieldTypeDescription
message string

Error reference

Errors are JSON objects with a "message" field. The dedicated statuses:

CaseDescription
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.