openapi: 3.1.0
info:
  title: messpunkt.io Partner API
  version: "0.1.0-draft"
  summary: |
    Partner-facing read API for consumption and meter data.
  description: |
    # Overview

    This API lets property-management ERPs read consumption and meter-reading
    data from messpunkt.io on behalf of a landlord (Tenant).

    **Status:** Preview · V1 in active development · **target release Q2 2026** · feedback from pilot partners explicitly invited.

    # Authentication

    All endpoints require **OAuth 2.1 Authorization Code + PKCE** with a
    **DPoP-bound** bearer token (RFC 9449). Tokens are scoped per Tenant.

    Connect flow:

    1. Landlord clicks "Connect messpunkt.io" inside the ERP.
    2. ERP redirects the browser to `/oauth/authorize` with
       `response_type=code`, `code_challenge_method=S256`, `scope`,
       `client_id`, `redirect_uri`, `state`.
    3. Landlord authenticates on messpunkt.io.
    4. **Consent screen** asks the landlord to (a) confirm the requested
       scopes and (b) **pick the Properties** the ERP is allowed to see
       (see *Authorization & Property scope* below). No Property is
       pre-selected — the landlord must explicitly choose.
    5. Browser is redirected back with an authorization code.
    6. ERP exchanges the code at `/oauth/token` with the PKCE verifier.
    7. ERP receives an access token (≤1 h, DPoP-bound, carrying the
       Property whitelist) and a rotating refresh token.

    Every request to the API carries:

    - `Authorization: DPoP <access_token>`
    - `DPoP: <dpop-proof JWT>` — a signed proof-of-possession token
      per RFC 9449.

    # Conventions

    - Media type: `application/json`; errors use `application/problem+json` (RFC 7807).
    - Timestamps: ISO 8601 UTC.
    - IDs: UUID v4.
    - Pagination: cursor-based (`page_cursor`, `next_cursor`, `prev_cursor`).
    - Versioning: `/v1` path prefix; deprecations signalled with `Sunset` header + 6 months overlap.
    - Rate limits: per `client_id`. `429` with `Retry-After` on exceedance.

    # Canonical model

    ```
    Tenant → Property → Address → UsageUnit → Sector
                                     → MeasuringPoint
                                         → Device (installation windows)
                                             → MeterReading
    ```

    `UsageUnit` is the ERP-facing resource for billing and meter-reading
    queries. `MeasuringPoint` is stable across device replacements.
    `Device` segments carry installation windows so meter replacement is
    explicit in the schema.

    ## Multi-Property Tenants

    A single Tenant (Hausverwaltung / property-management organisation)
    typically owns multiple `Property` records (Liegenschaften), each with
    its own address(es) and many UsageUnits. The API exposes both levels:

    - `GET /properties` — enumerate Properties in the Tenant
      (use this to populate a two-step picker in the ERP UI:
      *first pick the Liegenschaft, then the Wohneinheit*)
    - `GET /usage-units?property_id=…` — list units filtered to one Property
    - `GET /usage-units` (no filter) — flat list of all units in the Tenant
      (fine for Tenants with one Property, or ERPs that don't need grouping)

    Every `UsageUnit` also carries a lightweight `property` summary
    (`id`, `name`, short address) so a single `/usage-units` call is
    self-describing for small Tenants.

    **Important:** the endpoints above are additionally filtered by the
    token's `property_scope` — see the next section.

    # Authorization & Property scope

    Authentication (OAuth 2.1) proves *who* is calling. **Authorization**
    decides *what data* the caller is allowed to see within the Tenant.

    ![Authentication and Authorization flow](./auth-flow.svg)

    *The diagram covers all three phases: one-off consent with Property
    picker, authorized runtime requests with DPoP-bound tokens and
    server-side scope filtering, and later scope edits via the landlord
    dashboard. Source: `auth-flow.mmd` (Mermaid sequence diagram).*

    Because a Tenant often owns Properties that should not all be
    exposed to every ERP (different management mandates, pilot rollouts,
    data-protection boundaries), the API supports a **Property whitelist
    per connection**.

    ## How it works

    - **At consent time** the landlord picks one or more Properties from
      the Tenant's portfolio. Default is *nothing selected* — the landlord
      must actively choose.
    - The resulting access token carries a `property_scope` claim — either
      the literal string `"all"` (landlord chose to expose everything) or
      a list of Property UUIDs.
    - **Every API response is filtered** server-side against this
      whitelist. Endpoints that target a single Property, UsageUnit,
      MeasuringPoint, Device or MeterReading return **`404 Not Found`**
      if the resource is outside the whitelist — we deliberately do **not**
      return `403 Forbidden`, so the existence of out-of-scope Properties
      is not leaked to the ERP.
    - `GET /whoami` exposes the current `property_scope` so an ERP can
      display "you have access to N Properties" before issuing queries.

    ## Changing the scope later

    The landlord can edit the Property whitelist for an existing
    connection in the messpunkt.io dashboard **without re-consent**.
    Changes take effect on the next token refresh (at most within the
    access-token lifetime, ~1 h). In particular:

    - **Reducing** the whitelist is effective immediately for new tokens
      and at the latest within ~1 h for existing tokens.
    - **Extending** the whitelist follows the same path; there is no
      re-consent step, but the dashboard clearly shows which ERP is
      affected and logs the change in the audit trail.

    ## Implications for ERP clients

    - Always call `GET /whoami` after obtaining or refreshing a token and
      reconcile the local Property list.
    - Treat `404` on previously-known Property or UsageUnit IDs as a
      signal that access was revoked — surface a user-friendly message
      to the landlord and prompt a refresh of the picker.
    - Do not cache Property-level data across token refreshes without
      re-checking `property_scope`.

    # For AI agents

    This API is designed to be usable by AI assistants and LLM-based
    agents out of the box.

    ## Discovery

    - **[llms.txt](https://developer.messpunkt.io/llms.txt)** — llms.txt-standard index of the documentation
    - **[.well-known/ai-plugin.json](https://developer.messpunkt.io/.well-known/ai-plugin.json)** — plugin-style manifest pointing at this spec
    - **[.well-known/security.txt](https://developer.messpunkt.io/.well-known/security.txt)** — RFC 9116 responsible-disclosure contact
    - **[erp-api-openapi.yaml](https://developer.messpunkt.io/erp-api-openapi.yaml)** — this document, the authoritative machine-readable contract

    ## Tool-calling integration

    OpenAPI 3.1 operation schemas convert cleanly to:

    - Anthropic Tool Use (Claude)
    - OpenAI Function Calling / GPT Actions
    - Google Gemini Tool Use / Vertex AI Function Calling

    Because every response shape is fully typed (no `anyOf` escape
    hatches, no free-form JSON blobs), an LLM can reason about outputs
    structurally. `MeasuringPoint.obis` is deterministically derivable
    from `metric` — agents should prefer the OBIS code when comparing
    readings across partners.

    ## Typical LLM use cases

    - *"How much warm water did apartment WE 03 use from January to
      March 2026 compared to last year?"* → two `GET /usage-units/{id}/consumption`
      calls with different date ranges
    - *"Are any units in Reichenstraße 12 showing unusually high heat
      consumption?"* → `GET /properties?external_ref=…` then
      `GET /usage-units?property_id=…`, then per-unit consumption
    - *"When was the water meter in WE 03 last replaced?"* →
      inspect `DeviceSegment.deinstalled_at` and `.replacement_reason`
      in the consumption response
    - *"Which Properties is this ERP allowed to see?"* → `GET /whoami`

    ## Constraints for agent authors

    - Rate limits apply per `client_id`, not per token — share tokens
      within one application; don't parallelise aggressively across them
    - Always call `GET /whoami` after a token refresh to reconcile the
      current `property_scope` — a landlord may have edited it
    - `404` on a previously-known resource = scope revocation, not a
      retry condition. Surface this to the user.
    - V1 responses contain **no Mieter names** — tenant-identified
      consumption queries are not supported yet
    - Responses emit two readings per `DeviceSegment` (start + end of
      that segment within the query range). Intermediate snapshots will
      become available later via a `granularity=` query parameter.

    ## Roadmap

    - **Shipped (Tier 1):** Discovery artifacts + tool-schema mapping (this section).
    - **Planned (Tier 2):** **MCP server** — native integration with
      Claude Desktop, Cursor, ChatGPT Desktop, Zed, JetBrains, VS Code
      — so a landlord can ask their AI assistant about their own
      metering data without any intermediate ERP UI.
    - **Product decision (Tier 3):** conversational-analytics layer and
      LLM-assisted dispute resolution for Heizkostenabrechnung.

    # Meter identifiers (OBIS)

    Every `MeasuringPointConsumption` carries an `obis` string — an
    **OBIS code** (Object Identification System) as defined by
    **IEC 62056-6-1** (part of the DLMS/COSEM suite). OBIS gives every
    metered quantity a standardised, language-independent identifier, so
    an ERP can tell unambiguously what a value represents (e.g. cumulative
    water volume vs. instantaneous flow) regardless of vendor.

    **Format:** `A-B:C.D.E*F`

    | Group | Meaning | Example values |
    | --- | --- | --- |
    | **A** | Medium | `1` electricity · `4` heat cost allocator · `6` heat (Wärmezähler) · `7` gas · `8` cold water · `9` hot water · `0` abstract |
    | **B** | Channel | `0` or `1` for single-channel sub-meters |
    | **C** | Physical quantity | `1` energy/volume cumulative · `2` reactive · `3` gas operating volume · `8` time-integral (electricity) |
    | **D** | Processing / measurement type | `0` general · `8` cumulative total (electricity convention) |
    | **E** | Tariff / rate | `0` total · `1` tariff 1 · `2` tariff 2 |
    | **F** | Billing period | `255` current; `0..99` historical. Often omitted (`*F` left off) in German sub-metering. |

    **Typical codes per `Metric`** *(draft mapping; see §Metric schema)*:

    | Metric | Typical OBIS | Meaning |
    | --- | --- | --- |
    | `electricity` | `1-1:1.8.0` | Wirkarbeit Bezug, Zählerstand (kWh) |
    | `heat` | `6-1:1.0.0` | Wärmemenge kumulativ (kWh) |
    | `water_cold` | `8-1:1.0.0` | Kaltwasservolumen kumulativ (m³) |
    | `water_warm` | `9-1:1.0.0` | Warmwasservolumen kumulativ (m³) |
    | `gas` | `7-1:3.0.0` | Betriebsvolumen (m³) |
    | `hca` | `4-1:1.0.0` | HKV-Einheiten kumulativ (dimensionless) |

    > **Note on the draft.** The codes above follow IEC 62056-6-1 Edition 4.0
    > medium assignments and common sub-metering practice, but the German
    > BDEW/BNetzA *Codeliste der OBIS-Kennzahlen und Medien* formally covers
    > only electricity and gas (market communication); heat, water and HKV
    > lean on **EN 13757-1** and device-level conventions. Partners should
    > treat the per-metric mapping as **indicative for V1** — the precise
    > code for an individual device is reported by messpunkt.io alongside
    > the reading and should be the authoritative identifier.

    # Meter readings (Ablesungen / readouts)

    A *reading* (DE: *Ablesung*, also called *readout*) is one timestamped
    value produced by a physical meter (Device). Readings are exposed by
    two endpoints:

    | Endpoint | Scope | Typical use |
    | --- | --- | --- |
    | `GET /usage-units/{id}/consumption` | all MeasuringPoints of one UsageUnit, date range | unit-level billing export |
    | `GET /measuring-points/{id}/readings` | one MeasuringPoint, date range | drill-down / reconciliation |

    Both responses use the same nesting shape: per-MeasuringPoint, split
    into one `DeviceSegment` per continuous device installation window,
    with a `readings[]` array inside each segment. Meter replacement
    always starts a new segment.

    Each reading carries:

    - `at` — ISO 8601 UTC timestamp
    - `value` — numeric meter value
    - `status` — `measured` · `substituted` · `calculated` · `missing`
    - `substitution_method` — present when `status = substituted`

    ## Response contract — two readings per segment

    For a query range `[from, to]`, each `DeviceSegment` returns **exactly
    two readings**: the start and the end value for the device's coverage
    within the queried range. The ERP computes consumption per segment as
    `readings[1].value - readings[0].value` and sums across segments when a
    meter was replaced mid-range.

    Concretely, per segment:

    - `readings[0].at = max(segment.installed_at, from)`
    - `readings[1].at = min(segment.deinstalled_at or to, to)`
    - Either endpoint may be `status: "substituted"` if no actual meter
      telegram exists exactly at that timestamp — an interpolated value is
      then reported together with its `substitution_method`.
    - For a MeasuringPoint with no coverage at all in the range, the
      segment's `readings` is empty and `data_gap: true` is set on the
      MeasuringPoint.

    Intermediate readings (e.g. monthly snapshots for charting) are **not**
    included in V1; a future `granularity=` query parameter can add them
    without breaking this contract.

    ## Precision

    Every `DeviceSegment` carries a `resolution` field — the smallest
    increment the meter can report (e.g. `0.001` m³ = 1 litre for a
    typical residential water meter; `1` kWh for a heat meter;
    `1` HKV unit for an allocator). Partners should **display or store
    values at no finer precision than `resolution`** — the API may
    emit trailing zeros beyond the resolution on interpolated
    (`substituted`) values.

    When `status = substituted`, the returned value is an **interpolation
    of measured boundaries**, not a raw meter output. Its real uncertainty
    is governed by `substitution_method` (see `SubstitutionMethod`).

    MID / EN / IEC accuracy classes (e.g. `MID Class 2 R160`) are **not**
    exposed in V1 — they are valuable for eichrechtliche Nachweise
    but not for day-to-day billing logic. They may be added in a later
    version if a partner needs them.

    **References**
    - IEC 62056-6-1 Edition 4.0 (2023): https://webstore.iec.ch/publication/64347
    - EN 13757-1 (wM-Bus medium types, sub-metering)
    - EN 1434 (Wärmezähler / heat meters)
    - MID Directive 2014/32/EU (Measuring Instruments Directive): https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX%3A32014L0032
    - VDI 2170 (Heizkostenverteiler / HCA)
    - BDEW / BNetzA Codeliste der OBIS-Kennzahlen und Medien (electricity + gas, market communication): https://www.bdew-mako.de/
    - DLMS User Association (COSEM Blue Book, authoritative registry): https://www.dlms.com/
    - Overview article (German): https://de.wikipedia.org/wiki/OBIS-Kennzahlen
  contact:
    name: messpunkt.io Partnerships
    email: kontakt@messpunkt.io
    url: https://messpunkt.io
  termsOfService: https://messpunkt.io/impressum
  x-logo:
    url: ./assets/messpunkt-quer.svg
    altText: messpunkt.io
    backgroundColor: '#ffffff'
    href: https://messpunkt.io/

servers:
  - url: https://api.messpunkt.io/v1
    description: Production (V1)
  - url: https://api.sandbox.messpunkt.io/v1
    description: Sandbox

security:
  - OAuth2: [read:units, read:devices, read:readings]

tags:
  - name: Introspection
  - name: Properties
    description: Liegenschaften — top-level grouping of UsageUnits under a Tenant.
  - name: Units
  - name: Measuring points

paths:
  /whoami:
    get:
      tags: [Introspection]
      summary: Return identity and scopes of the current token
      description: |
        Lightweight token-introspection endpoint. ERPs should call this once
        after obtaining a token to verify the tenant and granted scopes
        before deeper queries.
      operationId: whoami
      security:
        - OAuth2: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WhoAmI"
              example:
                tenant_id: "9bb2c1ee-39c9-4eca-a83c-522e43c7be27"
                tenant_name: "Hausverwaltung Müller GmbH"
                client_id: "erp-client-01"
                granted_scopes: ["read:units", "read:devices", "read:readings"]
                property_scope:
                  - "718e092d-8626-4975-b800-252a7746ad08"
                  - "6d8be330-8f9d-4f75-b798-05a67c0074be"
                token_expires_at: "2026-04-16T21:30:00Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /properties:
    get:
      tags: [Properties]
      summary: List Properties (Liegenschaften) in the current Tenant
      description: |
        Paginated list of `Property` resources (Liegenschaften) accessible
        under the current token. Use this to populate the first step of a
        two-step picker in the ERP UI (*pick the Liegenschaft, then the
        Wohneinheit*). For small Tenants, ERPs may skip this and query
        `/usage-units` directly.
      operationId: listProperties
      security:
        - OAuth2: [read:units]
      parameters:
        - $ref: "#/components/parameters/PageCursor"
        - $ref: "#/components/parameters/Limit"
        - name: external_ref
          in: query
          description: Filter by the ERP's own reference (exact match).
          required: false
          schema: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Property" }
              example:
                next_cursor: null
                prev_cursor: null
                data:
                  - id: "718e092d-8626-4975-b800-252a7746ad08"
                    tenant_id: "9bb2c1ee-39c9-4eca-a83c-522e43c7be27"
                    external_ref: "erp-property-0815"
                    name: "Reichenstraße 12-14"
                    addresses:
                      - street: "Reichenstraße"
                        house_number: "12"
                        postal_code: "10999"
                        city: "Berlin"
                        country_code: "DE"
                      - street: "Reichenstraße"
                        house_number: "14"
                        postal_code: "10999"
                        city: "Berlin"
                        country_code: "DE"
                    usage_unit_count: 16
                  - id: "6d8be330-8f9d-4f75-b798-05a67c0074be"
                    tenant_id: "9bb2c1ee-39c9-4eca-a83c-522e43c7be27"
                    external_ref: null
                    name: "Forgerstraße 4"
                    addresses:
                      - street: "Forgerstraße"
                        house_number: "4"
                        postal_code: "12053"
                        city: "Berlin"
                        country_code: "DE"
                    usage_unit_count: 8
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /properties/{id}:
    parameters:
      - $ref: "#/components/parameters/PropertyId"
    get:
      tags: [Properties]
      summary: Get a single Property (Liegenschaft)
      description: |
        Returns Property metadata including its addresses and a unit count.
        The list of UsageUnits for this Property is available via
        `GET /usage-units?property_id={id}` (paginated).
      operationId: getProperty
      security:
        - OAuth2: [read:units]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Property" }
              example:
                id: "718e092d-8626-4975-b800-252a7746ad08"
                tenant_id: "9bb2c1ee-39c9-4eca-a83c-522e43c7be27"
                external_ref: "erp-property-0815"
                name: "Reichenstraße 12-14"
                addresses:
                  - street: "Reichenstraße"
                    house_number: "12"
                    postal_code: "10999"
                    city: "Berlin"
                    country_code: "DE"
                  - street: "Reichenstraße"
                    house_number: "14"
                    postal_code: "10999"
                    city: "Berlin"
                    country_code: "DE"
                usage_unit_count: 16
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /usage-units:
    get:
      tags: [Units]
      summary: List usage units in the current Tenant
      description: |
        Paginated list of `UsageUnit` resources accessible under the current
        token. Used by the ERP to present a picker for linking a
        messpunkt.io unit to the ERP's own unit record (with prefilled
        address, floor and position).
      operationId: listUsageUnits
      security:
        - OAuth2: [read:units]
      parameters:
        - $ref: "#/components/parameters/PageCursor"
        - $ref: "#/components/parameters/Limit"
        - name: external_ref
          in: query
          description: Filter by the ERP's own reference (exact match).
          required: false
          schema: { type: string }
        - name: property_id
          in: query
          description: Limit to units in a single Property.
          required: false
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/UsageUnit" }
              example:
                next_cursor: null
                prev_cursor: null
                data:
                  - id: "08d23bb4-0001-40f3-bd99-40bf33931365"
                    tenant_id: "9bb2c1ee-39c9-4eca-a83c-522e43c7be27"
                    property_id: "718e092d-8626-4975-b800-252a7746ad08"
                    property:
                      id: "718e092d-8626-4975-b800-252a7746ad08"
                      name: "Reichenstraße 12-14"
                      address_line: "Reichenstraße 12, 10999 Berlin"
                      external_ref: "erp-property-0815"
                    external_ref: "erp-unit-0815"
                    name: "WE 03, Müller"
                    floor: "2"
                    position: "links"
                    unit_type: "residential"
                    area_heated_m2: 72.4
                    area_ww_m2: 72.4
                    address:
                      street: "Reichenstraße"
                      house_number: "12"
                      postal_code: "10999"
                      city: "Berlin"
                      country_code: "DE"
                    measuring_points:
                      - measuring_point_id: "a45f8b4a-d595-49e0-8217-dd0b1c4a600f"
                        metric: "water_warm"
                        obis: "9-1:1.0.0"
                        unit: "m3"
                        active_device_serial: "12347"
                      - measuring_point_id: "b1359209-f047-4d7d-9f64-a3a4c43fba30"
                        metric: "heat"
                        obis: "6-1:1.0.0"
                        unit: "kWh"
                        active_device_serial: "HZ-0815-42"
                  - id: "c6699e51-c145-498a-8222-f84e6547d5dc"
                    tenant_id: "9bb2c1ee-39c9-4eca-a83c-522e43c7be27"
                    property_id: "718e092d-8626-4975-b800-252a7746ad08"
                    property:
                      id: "718e092d-8626-4975-b800-252a7746ad08"
                      name: "Reichenstraße 12-14"
                      address_line: "Reichenstraße 14, 10999 Berlin"
                      external_ref: "erp-property-0815"
                    external_ref: null
                    name: "WE 04, Schmidt"
                    floor: "2"
                    position: "rechts"
                    unit_type: "residential"
                    area_heated_m2: 68.1
                    area_ww_m2: 68.1
                    address:
                      street: "Reichenstraße"
                      house_number: "14"
                      postal_code: "10999"
                      city: "Berlin"
                      country_code: "DE"
                    measuring_points: []
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /usage-units/{id}:
    parameters:
      - $ref: "#/components/parameters/UsageUnitId"
    get:
      tags: [Units]
      summary: Get a single usage unit
      operationId: getUsageUnit
      security:
        - OAuth2: [read:units]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UsageUnit" }
              example:
                id: "08d23bb4-0001-40f3-bd99-40bf33931365"
                tenant_id: "9bb2c1ee-39c9-4eca-a83c-522e43c7be27"
                property_id: "718e092d-8626-4975-b800-252a7746ad08"
                property:
                  id: "718e092d-8626-4975-b800-252a7746ad08"
                  name: "Reichenstraße 12-14"
                  address_line: "Reichenstraße 12, 10999 Berlin"
                  external_ref: "erp-property-0815"
                external_ref: "erp-unit-0815"
                name: "WE 03, Müller"
                floor: "2"
                position: "links"
                unit_type: "residential"
                area_heated_m2: 72.4
                area_ww_m2: 72.4
                address:
                  street: "Reichenstraße"
                  house_number: "12"
                  postal_code: "10999"
                  city: "Berlin"
                  country_code: "DE"
                measuring_points:
                  - measuring_point_id: "a45f8b4a-d595-49e0-8217-dd0b1c4a600f"
                    metric: "water_warm"
                    obis: "9-1:1.0.0"
                    unit: "m3"
                    active_device_serial: "12347"
                  - measuring_point_id: "b1359209-f047-4d7d-9f64-a3a4c43fba30"
                    metric: "heat"
                    obis: "6-1:1.0.0"
                    unit: "kWh"
                    active_device_serial: "HZ-0815-42"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /usage-units/{id}/consumption:
    parameters:
      - $ref: "#/components/parameters/UsageUnitId"
    get:
      tags: [Units]
      summary: Consumption for a usage unit over a date range
      description: |
        **Primary read endpoint.**

        Returns every meter reading for the unit in `[from, to]`, grouped by
        MeasuringPoint and segmented by device installation window. Meter
        replacements appear as separate segments, so the ERP never has to diff
        serials to detect a swap.

        `status` on each reading distinguishes `measured` vs `substituted`
        (interpolated / forward-filled) vs `calculated` vs `missing`. A
        MeasuringPoint with an uncovered period returns a segment with
        `readings: []` and `data_gap: true` — never silent omission.
      operationId: getUsageUnitConsumption
      security:
        - OAuth2: [read:readings]
      parameters:
        - name: from
          in: query
          required: true
          description: Inclusive start of the query range (ISO 8601 date).
          schema: { type: string, format: date, example: "2026-01-01" }
        - name: to
          in: query
          required: true
          description: Inclusive end of the query range (ISO 8601 date).
          schema: { type: string, format: date, example: "2026-12-31" }
        - name: metric
          in: query
          description: Optional filter. Omit to return all metrics.
          required: false
          schema:
            type: array
            items: { $ref: "#/components/schemas/Metric" }
          style: form
          explode: false
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UnitConsumption" }
              example:
                usage_unit_id: "08d23bb4-0001-40f3-bd99-40bf33931365"
                external_ref: "erp-unit-0815"
                from: "2026-01-01"
                to: "2026-12-31"
                measuring_points:
                  - measuring_point_id: "a45f8b4a-d595-49e0-8217-dd0b1c4a600f"
                    metric: "water_warm"
                    obis: "9-1:1.0.0"
                    unit: "m3"
                    data_gap: false
                    segments:
                      - device_id: "613ee53e-e9dc-415f-a9d5-772422cedd23"
                        serial: "12345"
                        manufacturer: "SON"
                        installed_at: "2025-07-01"
                        deinstalled_at: "2026-06-30"
                        replacement_reason: "EndOfLife"
                        resolution: 0.001
                        readings:
                          - { at: "2026-01-01", value: 0.0,   status: "measured" }
                          - { at: "2026-06-30", value: 700.0, status: "substituted", substitution_method: "linear_interpolation" }
                      - device_id: "a263c9ee-8efa-4760-8be0-b6782e0f73d6"
                        serial: "12347"
                        manufacturer: "SON"
                        installed_at: "2026-07-01"
                        deinstalled_at: null
                        resolution: 0.001
                        readings:
                          - { at: "2026-07-01", value: 1.0,   status: "measured" }
                          - { at: "2026-12-31", value: 550.0, status: "measured" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /measuring-points/{id}:
    parameters:
      - $ref: "#/components/parameters/MeasuringPointId"
    get:
      tags: [Measuring points]
      summary: Get a measuring point with its current device
      operationId: getMeasuringPoint
      security:
        - OAuth2: [read:devices]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MeasuringPoint" }
              example:
                id: "a45f8b4a-d595-49e0-8217-dd0b1c4a600f"
                usage_unit_id: "08d23bb4-0001-40f3-bd99-40bf33931365"
                metric: "water_warm"
                obis: "9-1:1.0.0"
                unit: "m3"
                localization: "Badezimmer Steigstrang"
                active_device:
                  id: "a263c9ee-8efa-4760-8be0-b6782e0f73d6"
                  serial: "12347"
                  manufacturer: "SON"
                  device_type: "WarmWaterMeter"
                  installed_at: "2026-07-01"
                  deinstalled_at: null
                  replacement_reason: null
                  resolution: 0.001
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /measuring-points/{id}/readings:
    parameters:
      - $ref: "#/components/parameters/MeasuringPointId"
    get:
      tags: [Measuring points]
      summary: Readings (Ablesungen / readouts) for a single measuring point
      description: |
        **Readings / Ablesungen / readouts for one MeasuringPoint.**

        Drill-down endpoint: returns the same segmented structure as the
        unit-level `/usage-units/{id}/consumption`, but scoped to one
        MeasuringPoint. Each reading carries `at`, `value`, `status`
        (`measured` · `substituted` · `calculated` · `missing`) and, when
        applicable, `substitution_method`. Each device segment also carries
        `resolution` — see *Precision* in the API overview.
      operationId: getMeasuringPointReadings
      security:
        - OAuth2: [read:readings]
      parameters:
        - name: from
          in: query
          required: true
          schema: { type: string, format: date }
        - name: to
          in: query
          required: true
          schema: { type: string, format: date }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MeasuringPointConsumption" }
              examples:
                warmwater_with_replacement:
                  summary: Warmwater MeasuringPoint with a meter replacement mid-year
                  value:
                    measuring_point_id: "a45f8b4a-d595-49e0-8217-dd0b1c4a600f"
                    metric: "water_warm"
                    obis: "9-1:1.0.0"
                    unit: "m3"
                    data_gap: false
                    segments:
                      - device_id: "613ee53e-e9dc-415f-a9d5-772422cedd23"
                        serial: "12345"
                        manufacturer: "SON"
                        installed_at: "2025-07-01"
                        deinstalled_at: "2026-06-30"
                        replacement_reason: "EndOfLife"
                        resolution: 0.001
                        readings:
                          - { at: "2026-01-01T00:00:00Z", value: 0.000,   status: "measured" }
                          - { at: "2026-06-30T23:59:59Z", value: 700.000, status: "substituted", substitution_method: "linear_interpolation" }
                      - device_id: "a263c9ee-8efa-4760-8be0-b6782e0f73d6"
                        serial: "12347"
                        manufacturer: "SON"
                        installed_at: "2026-07-01"
                        deinstalled_at: null
                        replacement_reason: null
                        resolution: 0.001
                        readings:
                          - { at: "2026-07-01T00:00:00Z", value: 1.000,   status: "measured" }
                          - { at: "2026-12-31T23:59:59Z", value: 550.000, status: "measured" }
                heat_meter_continuous:
                  summary: Heat meter, no replacement — start + end of query range
                  value:
                    measuring_point_id: "08d23bb4-0001-40f3-bd99-40bf33931365"
                    metric: "heat"
                    obis: "6-1:1.0.0"
                    unit: "kWh"
                    data_gap: false
                    segments:
                      - device_id: "34adf046-8bdb-434e-b6c1-d41ad2bb2079"
                        serial: "HZ-0815-42"
                        manufacturer: "KAM"
                        installed_at: "2024-01-15"
                        deinstalled_at: null
                        replacement_reason: null
                        resolution: 1
                        readings:
                          - { at: "2026-01-01T00:00:00Z", value: 12480, status: "measured" }
                          - { at: "2026-11-30T23:59:59Z", value: 15640, status: "substituted", substitution_method: "degree_day_weighted" }
                hca_with_gap:
                  summary: Heat cost allocator with a data gap (device offline)
                  value:
                    measuring_point_id: "800332a8-30d7-445f-89ec-9a3a4606f451"
                    metric: "hca"
                    obis: "4-1:1.0.0"
                    unit: "unit"
                    data_gap: true
                    segments:
                      - device_id: "798f338c-6a3e-4b43-9960-ffe45d943904"
                        serial: "HKV-2024-0777"
                        manufacturer: "TECH"
                        installed_at: "2024-10-01"
                        deinstalled_at: null
                        replacement_reason: null
                        resolution: 1
                        readings: []
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

components:

  securitySchemes:
    OAuth2:
      type: oauth2
      description: |
        OAuth 2.1 with PKCE (S256) required. Access tokens are DPoP-bound
        (RFC 9449); callers must include a `DPoP` header with every request.
      flows:
        authorizationCode:
          authorizationUrl: https://auth.messpunkt.io/oauth/authorize
          tokenUrl: https://auth.messpunkt.io/oauth/token
          refreshUrl: https://auth.messpunkt.io/oauth/token
          scopes:
            "read:units":    Read UsageUnits, Addresses and structural metadata.
            "read:devices":  Read MeasuringPoints and Device lifecycle.
            "read:readings": Read MeterReadings and consumption details.

  parameters:
    PageCursor:
      name: page_cursor
      in: query
      required: false
      description: Opaque cursor returned by a previous response. Omit for first page.
      schema: { type: string }
    Limit:
      name: limit
      in: query
      required: false
      description: Max items per page.
      schema:
        type: integer
        minimum: 1
        maximum: 200
        default: 100
    PropertyId:
      name: id
      in: path
      required: true
      description: Property ID (UUID).
      schema: { type: string, format: uuid }
    UsageUnitId:
      name: id
      in: path
      required: true
      description: UsageUnit ID (UUID).
      schema: { type: string, format: uuid }
    MeasuringPointId:
      name: id
      in: path
      required: true
      description: MeasuringPoint ID (UUID).
      schema: { type: string, format: uuid }

  responses:
    BadRequest:
      description: Malformed request.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    Unauthorized:
      description: Missing or invalid token.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    Forbidden:
      description: Token valid but scope insufficient or resource out of tenant.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    NotFound:
      description: Resource not found in this Tenant.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    TooManyRequests:
      description: Rate limit exceeded. Retry after the period in `Retry-After`.
      headers:
        Retry-After:
          description: Seconds until the client may retry.
          schema: { type: integer }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }

  schemas:

    WhoAmI:
      type: object
      required: [tenant_id, tenant_name, client_id, granted_scopes, property_scope]
      properties:
        tenant_id:      { type: string, format: uuid }
        tenant_name:    { type: string, example: "Hausverwaltung Müller GmbH" }
        client_id:      { type: string, description: "ID of the ERP client holding the token." }
        granted_scopes:
          type: array
          items: { type: string }
          example: ["read:units", "read:readings"]
        property_scope:
          description: |
            Property-level authorization. Either the literal string `"all"`
            if the landlord exposed every Property in the Tenant, or an
            array of Property UUIDs the ERP is permitted to access. See
            *Authorization & Property scope* in the API overview.
          oneOf:
            - type: string
              enum: ["all"]
            - type: array
              items: { type: string, format: uuid }
          example: ["718e092d-8626-4975-b800-252a7746ad08", "6d8be330-8f9d-4f75-b798-05a67c0074be"]
        token_expires_at: { type: string, format: date-time }

    Page:
      type: object
      required: [next_cursor, prev_cursor]
      properties:
        next_cursor:
          type: [string, "null"]
          description: Opaque cursor for the next page. `null` when no more.
        prev_cursor:
          type: [string, "null"]
          description: Opaque cursor for the previous page. `null` on first page.

    Property:
      type: object
      required: [id, tenant_id, name, addresses]
      description: |
        A Liegenschaft — top-level grouping of UsageUnits under a Tenant.
        A Tenant typically owns multiple Properties, each with one or
        more addresses (Strassenzüge) and many UsageUnits.
      properties:
        id:             { type: string, format: uuid }
        tenant_id:      { type: string, format: uuid }
        external_ref:
          type: [string, "null"]
          description: Optional ERP-side identifier linked to this Property.
        name:
          type: string
          description: Landlord-facing Liegenschafts-Name.
          example: "Reichenstraße 12-14"
        addresses:
          type: array
          description: One or more addresses belonging to this Property.
          items: { $ref: "#/components/schemas/Address" }
        usage_unit_count:
          type: [integer, "null"]
          description: Number of UsageUnits in this Property at query time. May be null if not computed.
          example: 8

    PropertySummary:
      type: object
      required: [id, name]
      description: |
        Compact Property reference embedded inside a UsageUnit so a single
        `/usage-units` response is self-describing without a second call.
      properties:
        id:              { type: string, format: uuid }
        name:            { type: string, example: "Reichenstraße 12-14" }
        address_line:
          type: [string, "null"]
          description: One-line rendering of the primary Property address for UI display.
          example: "Reichenstraße 12, 10999 Berlin"
        external_ref:    { type: [string, "null"] }

    UsageUnit:
      type: object
      required: [id, tenant_id, property_id, property, address, name]
      properties:
        id:             { type: string, format: uuid }
        tenant_id:      { type: string, format: uuid }
        property_id:    { type: string, format: uuid }
        property:
          $ref: "#/components/schemas/PropertySummary"
        external_ref:
          type: [string, "null"]
          description: Optional ERP-side identifier linked to this unit.
        name:
          type: string
          description: Landlord-facing name, e.g. "WE 03, Müller".
          example: "WE 03, Müller"
        floor:          { type: [string, "null"], example: "2" }
        position:       { type: [string, "null"], example: "links" }
        unit_type:
          $ref: "#/components/schemas/UnitType"
        area_heated_m2: { type: [number, "null"], example: 72.4 }
        area_ww_m2:     { type: [number, "null"], example: 72.4 }
        address:        { $ref: "#/components/schemas/Address" }
        measuring_points:
          type: array
          description: Summary of MPs allocated to this unit. Use the drill-down endpoints for readings.
          items: { $ref: "#/components/schemas/MeasuringPointSummary" }

    UnitType:
      type: string
      description: |
        `residential` — Wohneinheit · `commercial` — Gewerbeeinheit ·
        `technical` — Heizzentrale etc. · `traffic` — Allgemeinflächen.
      enum: [residential, commercial, technical, traffic]

    Address:
      type: object
      required: [street, house_number, postal_code, city, country_code]
      properties:
        street:             { type: string, example: "Reichenstraße" }
        house_number:       { type: string, example: "12" }
        house_number_addition: { type: [string, "null"], example: "A" }
        postal_code:        { type: string, example: "10999" }
        city:               { type: string, example: "Berlin" }
        country_code:       { type: string, example: "DE", description: "ISO 3166-1 alpha-2" }

    MeasuringPointSummary:
      type: object
      required: [measuring_point_id, metric, unit]
      properties:
        measuring_point_id: { type: string, format: uuid }
        metric: { $ref: "#/components/schemas/Metric" }
        obis:
          type: [string, "null"]
          description: OBIS code identifying the physical quantity (IEC 62056-6-1). Format `A-B:C.D.E[*F]`.
          example: "9-1:1.0.0"
        unit:   { $ref: "#/components/schemas/MetricUnit" }
        active_device_serial: { type: [string, "null"] }

    MeasuringPoint:
      type: object
      required: [id, usage_unit_id, metric, unit, active_device]
      properties:
        id:            { type: string, format: uuid }
        usage_unit_id: { type: string, format: uuid }
        metric:        { $ref: "#/components/schemas/Metric" }
        obis:
          type: [string, "null"]
          description: OBIS code identifying the physical quantity (IEC 62056-6-1). Format `A-B:C.D.E[*F]`.
          example: "9-1:1.0.0"
        unit:          { $ref: "#/components/schemas/MetricUnit" }
        localization:
          type: [string, "null"]
          description: Free-text mount info, e.g. "Badezimmer Steigstrang".
        active_device: { $ref: "#/components/schemas/Device" }

    Device:
      type: object
      required: [id, serial, manufacturer, installed_at]
      properties:
        id:              { type: string, format: uuid }
        serial:          { type: string, example: "12345" }
        manufacturer:    { type: string, example: "SON" }
        device_type:     { type: string, example: "WarmWaterMeter" }
        installed_at:    { type: string, format: date }
        deinstalled_at:  { type: [string, "null"], format: date }
        replacement_reason:
          type: [string, "null"]
          enum: [null, EndOfLife, Defect, PeriodEnd, DeviceRemoval, Other]
        resolution:
          type: [number, "null"]
          description: |
            Smallest increment the meter can report, in the same unit as
            the reading `value`. Partners should not display or bill at
            finer precision than this. See *Precision* in the API overview.
          example: 0.001

    UnitConsumption:
      type: object
      required: [usage_unit_id, from, to, measuring_points]
      properties:
        usage_unit_id:   { type: string, format: uuid }
        external_ref:    { type: [string, "null"] }
        from:            { type: string, format: date }
        to:              { type: string, format: date }
        measuring_points:
          type: array
          items: { $ref: "#/components/schemas/MeasuringPointConsumption" }

    MeasuringPointConsumption:
      type: object
      required: [measuring_point_id, metric, unit, segments]
      properties:
        measuring_point_id: { type: string, format: uuid }
        metric:             { $ref: "#/components/schemas/Metric" }
        obis:
          type: [string, "null"]
          description: |
            OBIS code identifying the physical quantity of this MeasuringPoint,
            per **IEC 62056-6-1** (DLMS/COSEM). Format: `A-B:C.D.E[*F]`.
            See the `Meter identifiers (OBIS)` section in the API overview
            for the typical mapping per `Metric`.
          example: "9-1:1.0.0"
        unit:               { $ref: "#/components/schemas/MetricUnit" }
        data_gap:
          type: boolean
          default: false
          description: True when the range has no coverage at all (empty segments).
        segments:
          type: array
          description: |
            One entry per continuous device installation window within the
            queried range. A meter replacement produces two segments.
          items: { $ref: "#/components/schemas/DeviceSegment" }

    DeviceSegment:
      type: object
      required: [device_id, serial, manufacturer, installed_at, readings]
      properties:
        device_id:        { type: string, format: uuid }
        serial:           { type: string }
        manufacturer:     { type: string }
        installed_at:     { type: string, format: date }
        deinstalled_at:   { type: [string, "null"], format: date }
        replacement_reason:
          type: [string, "null"]
          enum: [null, EndOfLife, Defect, PeriodEnd, DeviceRemoval, Other]
        resolution:
          type: [number, "null"]
          description: Smallest increment the device can report, in the unit of `value`. See `Device.resolution`.
          example: 0.001
        readings:
          type: array
          description: Ablesungen (readouts) for this device within its installation window.
          items: { $ref: "#/components/schemas/MeterReading" }

    MeterReading:
      type: object
      required: [at, value, status]
      properties:
        at:    { type: string, format: date-time, description: "Reading timestamp (UTC)." }
        value: { type: number, example: 1234.567 }
        status:              { $ref: "#/components/schemas/ReadingStatus" }
        substitution_method: { $ref: "#/components/schemas/SubstitutionMethod" }

    ReadingStatus:
      type: string
      description: |
        Reading quality. Maps 1:1 to the internal `ValueType`.
        - `measured` — actual meter value from the device
        - `substituted` — interpolated or forward-filled to the queried boundary
        - `calculated` — derived (e.g. sum of sub-meters)
        - `missing` — no data available at this timestamp
      enum: [measured, substituted, calculated, missing]

    SubstitutionMethod:
      type: [string, "null"]
      description: |
        Present when `status = substituted`.
        - `linear_interpolation` — day-proportional, used for water / electricity / gas
        - `degree_day_weighted` — VDI 2067 monthly degree-day weighting, used for heat
        - `last_value_forward` — last known value carried forward
      enum: [null, linear_interpolation, degree_day_weighted, last_value_forward]

    Metric:
      type: string
      description: |
        High-level metric family. Each value maps to a typical OBIS code
        (see the `Meter identifiers (OBIS)` section in the API overview).

        | Value | German meter type | Typical OBIS | Unit |
        | --- | --- | --- | --- |
        | `heat` | Wärmezähler (WMZ) | `6-1:1.0.0` | `kWh` |
        | `hca` | Heizkostenverteiler (HKV) | `4-1:1.0.0` | `unit` |
        | `water_cold` | Kaltwasserzähler | `8-1:1.0.0` | `m3` |
        | `water_warm` | Warmwasserzähler | `9-1:1.0.0` | `m3` |
        | `electricity` | Stromzähler | `1-1:1.8.0` | `kWh` |
        | `gas` | Gaszähler | `7-1:3.0.0` | `m3` |

        The authoritative OBIS code for an individual device is always the
        value returned in the `obis` field on that MeasuringPoint.
      enum:
        - heat
        - hca
        - water_cold
        - water_warm
        - electricity
        - gas

    MetricUnit:
      type: string
      description: |
        Physical unit of the reading's `value`. Normalised on the server side —
        every `Metric` resolves to exactly one `MetricUnit`, so ERPs do not
        need to perform unit conversion:

        - `kWh` — energy (`heat`, `electricity`)
        - `m3`  — volume (`water_cold`, `water_warm`, `gas`)
        - `unit` — dimensionless HKV-Einheiten (`hca`)
      enum:
        - kWh
        - m3
        - unit

    Problem:
      description: RFC 7807 problem document.
      type: object
      required: [type, title, status]
      properties:
        type:
          type: string
          format: uri
          example: "https://api.messpunkt.io/errors/invalid-date-range"
        title:
          type: string
          example: "Invalid date range"
        status:
          type: integer
          example: 400
        detail:
          type: string
          example: "`to` must be on or after `from`."
        instance:
          type: string
          format: uri
        trace_id:
          type: string
          description: Correlate with server logs when opening a support ticket.
          example: "01HZ7K2XS9QJ0NB6K3GH7WN8XA"
