openapi: 3.1.0

info:
  title: CLARA API
  version: 1.0.0
  summary: >
    Street-level environmental intelligence: air quality, pedestrian wind
    comfort and thermal comfort, per address.
  description: |
    The CLARA API provides street-level environmental intelligence computed
    from city-scale simulation. It answers, for a location: what is the air
    quality, the pedestrian wind comfort and the thermal comfort, as annual
    statistics over the reference year, as live hourly conditions, and as
    generated reports.

    **Cities.** CLARA covers a growing set of cities, each with its own set
    of products. `GET /cities` is the machine-readable index: which cities
    exist, what each one offers, and each city's boundary. In Belgium,
    historical statistics and reports are additionally served nationwide:
    inside the Brussels high-resolution zone the answer comes from the
    street-level model, elsewhere in Belgium from a lower-resolution
    nationwide dataset, and `meta.dataset` always reports which one
    answered.

    **Products in this specification**

    * Cities index and boundaries (`GET /cities`, `GET /cities/{city}`).
    * Classification scales used by every product (`GET /scales`).
    * Key introspection (`GET /key`).
    * Address helper: forward geocoding for CLARA queries (`GET /geocode`).
    * Historical statistics: air quality, wind comfort and thermal comfort
      (`GET /historical/stats`).
    * Real-time conditions: live hourly records (`GET /realtime`).
    * Forecast conditions: upcoming hourly records (`GET /forecast`).
    * Environmental Intelligence Reports, HTML and PDF, generated
      asynchronously (`POST /reports` and its status and download
      endpoints).

    The map product (vector tile layers for city maps) has its own
    specification: see the CLARA Maps API at
    `https://www.clara.city/docs/openapi/map-v1.yaml`.

    **Authentication.** Every endpoint except `/health`, `/cities` and
    `/scales` requires an API key, sent as `Authorization: Bearer <key>`
    (or `X-API-Key`). Secret keys (`clara_sk_`) belong on your server.
    Publishable keys (`clara_pk_`) may be used from a browser page and are
    checked against the key's origin allowlist. Keys are scoped: each key
    carries the cities and products it may use, and `GET /key` echoes that
    scope. Keys are available by contacting the CLARA team via
    https://www.clara.city.

    **Errors.** All errors use RFC 9457 `application/problem+json` with a
    machine-readable `code`, a documentation link in `type` and the request
    correlation id in `request_id`. A `403` always concerns your key; a
    `404` or `422` always concerns the data.

    **Rate limits** are per key, per minute, plus a daily report quota.
    Every authenticated response carries `X-RateLimit-Limit`,
    `X-RateLimit-Remaining` and `X-RateLimit-Reset`; `429` responses for
    rate limiting include `Retry-After`.
  contact:
    name: CLARA (BuildWind)
    url: https://www.clara.city
    email: support@clara.city
  license:
    name: Proprietary
    url: https://www.clara.city

externalDocs:
  description: Human-readable documentation
  url: https://www.clara.city/docs/

servers:
  - url: https://api.clara.city/v1
    description: Production

security:
  - bearerAuth: []
  - apiKeyAuth: []

tags:
  - name: System
    description: Service health.
  - name: Cities
    description: >
      The city index: which cities CLARA covers, what each offers, and each
      city's boundary. Public, no key required.
  - name: Scales
    description: >
      The classification scales the products use, with class labels, colours
      and bounds. Public, no key required.
  - name: Keys
    description: What the presented API key may do.
  - name: Geocoding
    description: >
      Address helper: forward geocoding for CLARA queries. Results may only
      be used to query CLARA and must not be stored or redistributed.
  - name: Historical statistics
    description: >
      Reference-year air quality, wind comfort and thermal comfort
      statistics.
  - name: Real-time
    description: >
      Live hourly conditions: pedestrian-level wind, thermal comfort and
      pollutant concentrations.
  - name: Forecast
    description: Upcoming hourly conditions, same record shape as real-time.
  - name: Reports
    description: >
      Asynchronous Environmental Intelligence Report generation, HTML and
      PDF.

paths:
  /health:
    get:
      operationId: getHealth
      tags: [System]
      summary: Service health check
      description: No authentication required. Not rate limited.
      security: []
      responses:
        "200":
          description: Service is up.
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Health"
              example:
                service: clara-api
                status: ok
                version: 1.0.0
                time: "2026-08-20T10:15:42Z"

  /cities:
    get:
      operationId: listCities
      tags: [Cities]
      summary: Every city CLARA covers, with its products
      description: |
        The machine-readable city index. Public: without a key it returns
        the full list; with a key, each entry additionally carries
        `authorized`, whether the presented key is scoped to that city.

        Use `product` to filter, for example `?product=map` for the cities
        that have map coverage.

        The response is served with `Cache-Control: public, max-age=3600`.
      security: []
      parameters:
        - name: product
          in: query
          required: false
          description: Only return cities that have this product.
          schema:
            type: string
            enum: [geocode, historical, realtime, forecast, reports, map]
      responses:
        "200":
          description: The city index.
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CityIndex"
              example:
                generated_utc: "2026-08-20T10:15:42Z"
                cities:
                  - id: brussels
                    name: Brussels
                    country: BE
                    timezone: Europe/Brussels
                    bbox: [4.30421, 50.79655, 4.41729, 50.87949]
                    products:
                      geocode: true
                      historical: true
                      realtime: true
                      forecast: true
                      reports: true
                      map: true
                    map:
                      layers: [thermal, wind, air, buildings]
                      forecast_hours: 48
                      cadence: hourly
                  - id: ravenna
                    name: Ravenna
                    country: IT
                    timezone: Europe/Rome
                    bbox: [12.16683, 44.38422, 12.23421, 44.44054]
                    products:
                      geocode: false
                      historical: false
                      realtime: true
                      forecast: true
                      reports: false
                      map: true
                    map:
                      layers: [thermal, wind, buildings]
                      forecast_hours: 48
                      cadence: hourly

  /cities/{city}:
    get:
      operationId: getCity
      tags: [Cities]
      summary: One city, including its boundary polygon
      description: |
        Everything `GET /cities` reports for the city, plus the exact
        boundary as a GeoJSON Feature (`Polygon` geometry, `[lon, lat]`
        coordinate order). Draw the boundary on a map, or use it to
        pre-filter locations without spending data requests.

        The response is served with `Cache-Control: public, max-age=3600`;
        it changes only when coverage changes, and the hour bounds how long a
        cached copy can disagree once it does.
      security: []
      parameters:
        - $ref: "#/components/parameters/City"
      responses:
        "200":
          description: The city description.
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CityDetail"
        "404":
          $ref: "#/components/responses/NotFound"

  /scales:
    get:
      operationId: listScales
      tags: [Scales]
      summary: What each metric can be plotted as, and how to colour it
      description: |
        Two levels, because the constraint is real: you cannot paint a
        comfort classification onto a gust reading.

        A **view** is what to plot from a metric. Wind can be drawn as the
        mean pedestrian speed, as the gusts, or as the combined comfort and
        safety classes; air as the index or as one pollutant. Each view names
        the scales valid for it and which of them is its default.

        A **scale** is how to colour one view: its classes, their colours and
        their bounds. These are the definitions behind every coloured value
        CLARA renders, published once so that clients never hardcode them.

        All views of a layer read the same vector tile, so switching view is
        a repaint rather than a second request.

        The response is served with `Cache-Control: public, max-age=3600`
        and an ETag, so a revalidation costs a 304 with no body. A scale
        changes rarely, but when it does an hour bounds how long a cached
        copy can disagree with the map.
      security: []
      parameters:
        - name: metric
          in: query
          required: false
          description: Limit the answer to one metric.
          schema:
            type: string
            enum: [air, wind, thermal]
          example: wind
      responses:
        "400":
          $ref: "#/components/responses/ValidationError"
        "200":
          description: The views and the scales.
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
          content:
            application/json:
              schema:
                type: object
                required: [views, scales]
                properties:
                  views:
                    type: array
                    items:
                      $ref: "#/components/schemas/View"
                  scales:
                    type: array
                    items:
                      $ref: "#/components/schemas/ScaleSummary"
              example:
                views:
                  - id: comfort-safety
                    metric: wind
                    name: Wind comfort and safety
                    description: >-
                      The five CLARA comfort classes, read from the mean wind
                      speed and the gusts together. The only view that shows
                      the Danger condition.
                    fields: [Ucomfort, Ugust]
                    scales: [wind-nen8100, wind-cvd]
                    default_scale: wind-nen8100
                    default: true
                  - id: mean
                    metric: wind
                    name: Wind
                    description: Mean pedestrian wind speed in m/s, on its own.
                    fields: [Ucomfort]
                    scales: [wind-mean-rainbow]
                    default_scale: wind-mean-rainbow
                  - id: gust
                    metric: wind
                    name: Gust
                    description: Gust speed in m/s, on its own.
                    fields: [Ugust]
                    scales: [wind-gust-rainbow]
                    default_scale: wind-gust-rainbow
                scales:
                  - id: wind-nen8100
                    metric: wind
                    view: comfort-safety
                    name: NEN 8100 colours
                    authority: CLARA (Lawson-based), NEN 8100 palette
                    kind: classes
                    default: true
                  - id: wind-cvd
                    metric: wind
                    view: comfort-safety
                    name: Colour-blind friendly
                    authority: CLARA (Lawson-based)
                    kind: classes
                  - id: wind-mean-rainbow
                    metric: wind
                    view: mean
                    name: Smooth rainbow
                    authority: CLARA (ParaView Blue to Red Rainbow)
                    kind: gradient
                    default: true
                  - id: wind-gust-rainbow
                    metric: wind
                    view: gust
                    name: Smooth rainbow
                    authority: CLARA (ParaView Blue to Red Rainbow)
                    kind: gradient
                    default: true

  /scales/{scale}:
    get:
      operationId: getScale
      tags: [Scales]
      summary: One scale, with classes, labels, colours and bounds
      description: |
        The full definition of one scale. `kind` says how to consume it:
        `classes` is a swatch list, `gradient` is a colour bar.

        Which of four shapes the classes take depends on what the scale is:

        - `thresholds` present: an index over several fields, worst one wins
          (`air-belaqi`).
        - `classes[].when` present: machine-readable tests on one or more
          fields; a feature takes the highest `value` whose rule matches
          (`wind-nen8100`, `wind-cvd`).
        - `classes[].bounds` on a `classes` scale: ranges on one field,
          exclusive-lower and inclusive-upper (`thermal-utci`).
        - `kind: gradient`: bands, lower-inclusive, or with `interpolate`
          the stops to interpolate between.
      security: []
      parameters:
        - name: scale
          in: path
          required: true
          description: Scale identifier, from `GET /scales`.
          schema:
            type: string
            enum:
              [
                wind-nen8100,
                wind-cvd,
                wind-mean-rainbow,
                wind-gust-rainbow,
                air-belaqi,
                air-no2,
                air-pm25,
                air-pm10,
                thermal-utci,
              ]
          example: wind-nen8100
      responses:
        "200":
          description: The scale definition.
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Scale"
              example:
                id: wind-nen8100
                metric: wind
                view: comfort-safety
                name: Standard
                authority: CLARA (Lawson-based)
                description: >-
                  The CLARA pedestrian wind comfort classes, from the hourly
                  mean pedestrian wind speed (Ucomfort) and gust speed (Ugust)
                  in m/s, drawn in the standard CLARA class colours. A
                  feature belongs to the highest class whose condition it
                  meets.
                kind: classes
                fields: [Ucomfort, Ugust]
                default: true
                classes:
                  - value: 1
                    label: Calm
                    color: "#0000FF"
                    meaning: Comfortable for all activities
                    condition: Ucomfort < 4 and Ugust < 10
                    when:
                      all:
                        - { field: Ucomfort, lt: 4 }
                        - { field: Ugust, lt: 10 }
                  - value: 4
                    label: Very windy
                    color: "#ff8000"
                    meaning: Uncomfortable for all activities
                    condition: Ucomfort > 8 or Ugust >= 10
                    when:
                      any:
                        - { field: Ucomfort, gt: 8 }
                        - { field: Ugust, gte: 10 }
                  - value: 5
                    label: Danger
                    color: "#FF8C00"
                    condition: Ucomfort > 15 or Ugust > 15
                    when:
                      any:
                        - { field: Ucomfort, gt: 15 }
                        - { field: Ugust, gt: 15 }
                no_data_color: "#aaa4a4"
        "404":
          $ref: "#/components/responses/NotFound"

  /key:
    get:
      operationId: getKey
      tags: [Keys]
      summary: What the presented key may do
      description: >
        Echoes the presented key's label, type, city and product scope, and
        limits. The first call to make after receiving a key: it confirms
        the key works and shows exactly what it can reach.
      responses:
        "200":
          description: The key's scope.
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/KeyInfo"
              example:
                label: immoweb
                type: sk
                cities: [brussels]
                products: [geocode, historical, realtime, forecast, reports]
                limits:
                  rate_per_min: 100
                  reports_per_day: 100
                created: "2026-08-20"
                expires_at: null
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"

  /geocode:
    get:
      operationId: geocode
      tags: [Geocoding]
      summary: Forward-geocode an address for CLARA queries
      description: |
        Resolves a free-text address to coordinates, as a helper for calling
        the data endpoints. Results are for CLARA queries only: they must
        not be stored or redistributed, and CLARA does not offer geocoding
        as a standalone service. `results` may be an empty array when
        nothing matches.

        Every result carries `covered`, whether it lies inside a CLARA
        high-resolution zone, and `city`, the covering city's identifier,
        when it does.
      parameters:
        - name: q
          in: query
          required: true
          description: Free-text address query.
          schema:
            type: string
            minLength: 1
          example: "Chaussée de Waterloo 481, Ixelles"
        - name: limit
          in: query
          required: false
          description: Maximum number of results (1-5). Default 5.
          schema:
            type: integer
            minimum: 1
            maximum: 5
            default: 5
      responses:
        "200":
          description: Geocoding results (possibly empty).
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GeocodeResponse"
              example:
                query: "Chaussée de Waterloo 481, Ixelles"
                results:
                  - address: "Chaussée de Waterloo 481, 1050 Ixelles"
                    lat: 50.82068
                    lon: 4.35821
                    relevance: 1.0
                    covered: true
                    city: brussels
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        "502":
          $ref: "#/components/responses/UpstreamError"

  /historical/stats:
    get:
      operationId: getHistoricalStats
      tags: [Historical statistics]
      summary: Historical air quality, wind and thermal statistics for a location
      description: |
        Returns the reference-year statistics for one location.

        Provide **either** `address` **or** both `lat` and `lon`; supplying
        both at once returns `400 validation_error`. With `address`, the top
        geocoding result is used. Coordinates are rounded to 5 decimals
        before processing.

        Any location in Belgium is served: inside the Brussels
        high-resolution zone (boundary at `GET /cities/brussels`) the answer
        comes from the street-level model, elsewhere from a lower-resolution
        nationwide dataset. `meta.dataset` reports which one answered, and
        `city` is present when the location falls inside a covered city.
        Locations outside every covered area receive `422 outside_coverage`.

        By default no radius is applied and the answer is the value AT the
        location: the grid cell containing the point, or, when the point
        falls inside a building, as geocoded addresses normally do, the
        outdoor cells at the nearest distance plus half a grid cell.
        Building-interior cells are never read. `meta.sampling` says which
        rule was used and `meta.distance_m` how far the value came from.
        Supplying `radius` averages deliberately over that area instead,
        with no widening. Nationwide answers always use the cell containing
        the location; `radius` has no effect on them.

        Results are cached server-side per (lat, lon, radius) triple; the
        `X-Cache` header reports `hit` or `miss`. Use `metrics` to request
        a subset of the `air`, `wind` and `thermal` blocks.
      parameters:
        - name: address
          in: query
          required: false
          description: Free-text address (alternative to `lat` and `lon`).
          schema:
            type: string
          example: "Chaussée de Waterloo 481, Ixelles"
        - name: lat
          in: query
          required: false
          description: Latitude in decimal degrees (WGS 84).
          schema:
            type: number
          example: 50.82068
        - name: lon
          in: query
          required: false
          description: Longitude in decimal degrees (WGS 84).
          schema:
            type: number
          example: 4.35821
        - name: radius
          in: query
          required: false
          description: >
            OPTIONAL averaging radius in metres. Omit it (recommended) to
            get the value AT the location. Supply it only to average
            deliberately over an area; no automatic widening is applied, so
            a radius that contains no outdoor cell returns 422 rather than a
            value from further away.
          schema:
            type: number
            minimum: 5
            maximum: 50
        - name: metrics
          in: query
          required: false
          description: >
            Comma-separated subset of `air,wind,thermal` (any order). Omit it
            to receive every metric your key holds; `meta.metrics` reports
            which those were. Naming a metric your key does not hold answers
            403 `forbidden` rather than quietly returning less.
          schema:
            type: string
            pattern: '^(air|wind|thermal)(,(air|wind|thermal)){0,2}$'
          example: "air,thermal"
      responses:
        "200":
          description: Historical statistics for the location.
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
            X-Cache:
              $ref: "#/components/headers/XCache"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatsResponse"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/OutsideCoverage"
        "429":
          $ref: "#/components/responses/RateLimited"
        "502":
          $ref: "#/components/responses/UpstreamError"
        "504":
          $ref: "#/components/responses/UpstreamTimeout"

  /realtime:
    get:
      operationId: getRealtime
      tags: [Real-time]
      summary: Live hourly conditions for a location
      description: |
        Returns live hourly records for one location: pedestrian-level wind
        speeds (`ucomfort`, `ugust`), thermal comfort (`utci`) and pollutant
        concentrations, using the model data within `radius` metres of the
        point.

        Provide **either** `address` **or** both `lat` and `lon`; supplying
        both at once returns `400 validation_error`. Coordinates are rounded
        to 5 decimals before processing. There is no `metrics` parameter:
        every record carries all its fields.

        The location must fall inside a covered city that has the real-time
        product; `GET /cities?product=realtime` lists them. Points outside
        every covered city receive an instant `422 outside_coverage`; points
        inside a city that lacks real-time receive `422
        product_unavailable`.

        The records in `hourly` are passed through verbatim from the
        real-time model, one record per hour; `meta.hours` is the number of
        records returned.

        Responses are cached server-side for 5 minutes per
        (lat, lon, radius) triple; the `X-Cache` header reports `hit` or
        `miss`. `meta.retrieved_utc` is when the response was assembled; the
        hourly payload itself may be up to 5 minutes older.
      parameters:
        - $ref: "#/components/parameters/Address"
        - $ref: "#/components/parameters/Lat"
        - $ref: "#/components/parameters/Lon"
        - $ref: "#/components/parameters/Radius"
      responses:
        "200":
          description: Live hourly conditions for the location.
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
            X-Cache:
              $ref: "#/components/headers/XCache"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RealtimeResponse"
              example:
                city: { id: brussels, name: Brussels, country: BE }
                location:
                  address: null
                  lat: 50.82068
                  lon: 4.35821
                meta:
                  dataset: brussels_extended
                  resolution_m: 8
                  radius_m: 10
                  hours: 2
                  retrieved_utc: "2026-08-20T10:15:42Z"
                hourly:
                  - hour: "2026-08-20T09:00:00.000Z"
                    timestamp: "1787216400000"
                    c: 18.4
                    ugust: 2.1
                    ucomfort: 1.2
                    utci: 24.6
                    c_pm10: 11.0
                    c_pm25: 6.2
                    c_o3: 48.1
                  - hour: "2026-08-20T10:00:00.000Z"
                    timestamp: "1787220000000"
                    c: 17.9
                    ugust: 2.4
                    ucomfort: 1.3
                    utci: 25.2
                    c_pm10: 10.6
                    c_pm25: 5.9
                    c_o3: 51.3
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/OutsideCoverageOrProductUnavailable"
        "429":
          $ref: "#/components/responses/RateLimited"
        "502":
          $ref: "#/components/responses/UpstreamError"
        "504":
          $ref: "#/components/responses/UpstreamTimeout"

  /forecast:
    get:
      operationId: getForecast
      tags: [Forecast]
      summary: Upcoming hourly conditions for a location
      description: |
        Returns forecast hourly records for one location, out to roughly 48
        hours, in the same record shape as `GET /realtime`. The spacing
        between records may be irregular; read each record's timestamp
        rather than assuming a fixed interval.

        Availability and gating are identical to `/realtime`: the location
        must fall inside a covered city that has the forecast product
        (`GET /cities?product=forecast`).
      parameters:
        - $ref: "#/components/parameters/Address"
        - $ref: "#/components/parameters/Lat"
        - $ref: "#/components/parameters/Lon"
        - $ref: "#/components/parameters/Radius"
      responses:
        "200":
          description: Forecast hourly conditions for the location.
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
            X-Cache:
              $ref: "#/components/headers/XCache"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RealtimeResponse"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/OutsideCoverageOrProductUnavailable"
        "429":
          $ref: "#/components/responses/RateLimited"
        "502":
          $ref: "#/components/responses/UpstreamError"
        "504":
          $ref: "#/components/responses/UpstreamTimeout"

  /reports:
    post:
      operationId: createReport
      tags: [Reports]
      summary: Request an Environmental Intelligence Report
      description: |
        Starts asynchronous generation of a report (HTML plus PDF) for one
        location. Provide **either** `address` **or** both `lat` and `lon`.
        When only coordinates are given, the address label is resolved by
        reverse geocoding, falling back to "lat, lon" formatted to 5
        decimals.

        Any location in Belgium is accepted: inside the Brussels
        high-resolution zone the report is built from the street-level
        model, elsewhere from the lower-resolution nationwide dataset.
        Locations outside every covered area receive an instant
        `422 outside_coverage` without any work starting.

        Counts against the key's daily report quota, checked before any
        work starts. Poll `GET /v1/reports/{job_id}` for progress;
        generated files are retained for 30 days.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ReportRequest"
            examples:
              byAddress:
                summary: By address
                value:
                  address: "Chaussée de Waterloo 481, 1050 Ixelles"
                  lang: en
              byCoordinates:
                summary: By coordinates
                value:
                  lat: 50.82068
                  lon: 4.35821
                  radius: 10
                  lang: fr
      responses:
        "202":
          description: Report generation accepted.
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReportAccepted"
              example:
                job_id: 3f8a2c1e-9b4d-4e6a-8c2f-1d5e7a9b0c3d
                status: processing
                stage: queued
                links:
                  status: /v1/reports/3f8a2c1e-9b4d-4e6a-8c2f-1d5e7a9b0c3d
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/OutsideCoverage"
        "429":
          $ref: "#/components/responses/RateLimitedOrQuotaExceeded"
        "502":
          $ref: "#/components/responses/UpstreamError"
        "504":
          $ref: "#/components/responses/UpstreamTimeout"

  /reports/{job_id}:
    get:
      operationId: getReportStatus
      tags: [Reports]
      summary: Poll report status
      description: |
        Returns the current state of a report job. While the job is running,
        `status` is `processing` with `stage` `queued`, `running` or
        `delivering`. When the report is ready, `status` is `completed`
        with download links for the HTML and PDF files. The PDF is rendered
        on the first status poll after the HTML is ready; if that rendering
        is still pending, the response is `completed` with
        `pdf_pending: true` and no `files.pdf`; poll again to retry. A
        permanently failed job returns `status: failed` with `detail`.
      parameters:
        - $ref: "#/components/parameters/JobId"
      responses:
        "200":
          description: Current job state.
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReportStatus"
              examples:
                processing:
                  summary: Still processing
                  value:
                    job_id: 3f8a2c1e-9b4d-4e6a-8c2f-1d5e7a9b0c3d
                    status: processing
                    stage: running
                completed:
                  summary: Completed (both files ready)
                  value:
                    job_id: 3f8a2c1e-9b4d-4e6a-8c2f-1d5e7a9b0c3d
                    status: completed
                    address: "Chaussée de Waterloo 481, 1050 Ixelles"
                    lang: en
                    files:
                      html:
                        url: /v1/reports/3f8a2c1e-9b4d-4e6a-8c2f-1d5e7a9b0c3d/download?format=html
                      pdf:
                        url: /v1/reports/3f8a2c1e-9b4d-4e6a-8c2f-1d5e7a9b0c3d/download?format=pdf
                    expires_in_days: 30
                failed:
                  summary: Failed
                  value:
                    job_id: 3f8a2c1e-9b4d-4e6a-8c2f-1d5e7a9b0c3d
                    status: failed
                    detail: report generation failed
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "502":
          $ref: "#/components/responses/UpstreamError"
        "504":
          $ref: "#/components/responses/UpstreamTimeout"

  /reports/{job_id}/download:
    get:
      operationId: downloadReport
      tags: [Reports]
      summary: Download a generated report file
      description: |
        Streams the generated report in the requested format. The response
        is sent as an attachment named `CLARA_Report_{slug}.{html|pdf}`,
        where `slug` is derived from the report address. Returns `404` if
        the file does not exist: unknown job, not yet generated, or expired
        after 30 days.
      parameters:
        - $ref: "#/components/parameters/JobId"
        - name: format
          in: query
          required: true
          description: File format to download.
          schema:
            type: string
            enum: [html, pdf]
      responses:
        "200":
          description: The report file.
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
            Content-Disposition:
              description: >
                `attachment; filename="CLARA_Report_{slug}.{ext}"`.
              schema:
                type: string
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            text/html; charset=utf-8:
              schema:
                type: string
                description: Self-contained HTML report.
            application/pdf:
              schema:
                description: A4 PDF report (binary).
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: >
        API key in the Authorization header: `Authorization: Bearer <key>`.
        Secret keys (`clara_sk_`) are server side only; publishable keys
        (`clara_pk_`) may be used from a browser page and are checked
        against the key's origin allowlist.
    apiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: Alternative header carrying the same key.

  parameters:
    City:
      name: city
      in: path
      required: true
      description: City identifier, lowercase, from `GET /cities`.
      schema:
        type: string
        pattern: "^[a-z][a-z0-9-]*$"
      example: brussels
    JobId:
      name: job_id
      in: path
      required: true
      description: Report job id (UUID v4). Malformed ids return `400`.
      schema:
        type: string
        format: uuid
        pattern: "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
      example: 3f8a2c1e-9b4d-4e6a-8c2f-1d5e7a9b0c3d
    Address:
      name: address
      in: query
      required: false
      description: Free-text address (alternative to `lat` and `lon`).
      schema:
        type: string
      example: "Chaussée de Waterloo 481, Ixelles"
    Lat:
      name: lat
      in: query
      required: false
      description: Latitude in decimal degrees (WGS 84).
      schema:
        type: number
      example: 50.82068
    Lon:
      name: lon
      in: query
      required: false
      description: Longitude in decimal degrees (WGS 84).
      schema:
        type: number
      example: 4.35821
    Radius:
      name: radius
      in: query
      required: false
      description: Averaging radius in metres.
      schema:
        type: number
        minimum: 5
        maximum: 50
        default: 10

  headers:
    XRequestId:
      description: Request correlation id (UUID v4), echoed on every response.
      schema:
        type: string
        format: uuid
    XRateLimitLimit:
      description: Requests allowed per minute for this API key.
      schema:
        type: integer
      example: 100
    XRateLimitRemaining:
      description: Requests remaining in the current minute window.
      schema:
        type: integer
      example: 97
    XRateLimitReset:
      description: Start of the next minute window, in epoch seconds.
      schema:
        type: integer
      example: 1787220060
    XCache:
      description: Whether the response body was served from the server-side cache.
      schema:
        type: string
        enum: [hit, miss]
    RetryAfter:
      description: Seconds to wait before retrying (rate limiting only).
      schema:
        type: integer
      example: 23

  responses:
    ValidationError:
      description: Invalid or missing parameters.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
          example:
            type: https://www.clara.city/docs/errors#validation_error
            code: validation_error
            title: Validation error
            status: 400
            detail: "radius must be between 5 and 50 metres"
            request_id: 7c1f4c2a-0f3e-4d2b-9a6e-5b8d0c1e2f3a
    Unauthorized:
      description: Missing or invalid API key.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
          example:
            type: https://www.clara.city/docs/errors#unauthorized
            code: unauthorized
            title: Unauthorized
            status: 401
            detail: "missing or invalid API key"
            request_id: 7c1f4c2a-0f3e-4d2b-9a6e-5b8d0c1e2f3a
    Forbidden:
      description: >
        The key is valid but not entitled: deactivated, expired, not scoped
        to the city the location falls in, or the product is not included.
        A `403` always means the key; the data endpoints themselves are
        working.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
          examples:
            deactivated:
              summary: Key deactivated
              value:
                type: https://www.clara.city/docs/errors#forbidden
                code: forbidden
                title: Forbidden
                status: 403
                detail: "this API key has been deactivated"
                request_id: 7c1f4c2a-0f3e-4d2b-9a6e-5b8d0c1e2f3a
            citt:
              summary: Key not scoped to the city
              value:
                type: https://www.clara.city/docs/errors#forbidden
                code: forbidden
                title: Forbidden
                status: 403
                detail: "this key is not scoped to the city \"ravenna\""
                request_id: 7c1f4c2a-0f3e-4d2b-9a6e-5b8d0c1e2f3a
            product:
              summary: Product not included
              value:
                type: https://www.clara.city/docs/errors#forbidden
                code: forbidden
                title: Forbidden
                status: 403
                detail: "this key does not include the reports product"
                request_id: 7c1f4c2a-0f3e-4d2b-9a6e-5b8d0c1e2f3a
    NotFound:
      description: Unknown resource.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
          example:
            type: https://www.clara.city/docs/errors#not_found
            code: not_found
            title: Not found
            status: 404
            detail: "no report job with this id"
            request_id: 7c1f4c2a-0f3e-4d2b-9a6e-5b8d0c1e2f3a
    OutsideCoverage:
      description: >
        The location is outside every covered area, or the address could
        not be resolved inside one. Partner UIs should treat this as "not
        covered yet", never as an error state; see `GET /cities` for the
        covered areas.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
          example:
            type: https://www.clara.city/docs/errors#outside_coverage
            code: outside_coverage
            title: Outside coverage area
            status: 422
            detail: "this location is outside the CLARA service area"
            request_id: 7c1f4c2a-0f3e-4d2b-9a6e-5b8d0c1e2f3a
    OutsideCoverageOrProductUnavailable:
      description: >
        The location is outside every covered area (`outside_coverage`), or
        it falls inside a covered city that does not have this product
        (`product_unavailable`).
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
          examples:
            outsideCoverage:
              summary: Outside every covered area
              value:
                type: https://www.clara.city/docs/errors#outside_coverage
                code: outside_coverage
                title: Outside coverage area
                status: 422
                detail: "this location is outside the CLARA service area"
                request_id: 7c1f4c2a-0f3e-4d2b-9a6e-5b8d0c1e2f3a
            productUnavailable:
              summary: City lacks this product
              value:
                type: https://www.clara.city/docs/errors#product_unavailable
                code: product_unavailable
                title: Product unavailable here
                status: 422
                detail: "real-time data is not available in \"ravenna\""
                request_id: 7c1f4c2a-0f3e-4d2b-9a6e-5b8d0c1e2f3a
    RateLimited:
      description: Per-minute rate limit exceeded.
      headers:
        Retry-After:
          $ref: "#/components/headers/RetryAfter"
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
          example:
            type: https://www.clara.city/docs/errors#rate_limited
            code: rate_limited
            title: Rate limit exceeded
            status: 429
            detail: "rate limit of 100 requests per minute exceeded"
            request_id: 7c1f4c2a-0f3e-4d2b-9a6e-5b8d0c1e2f3a
    RateLimitedOrQuotaExceeded:
      description: >
        Per-minute rate limit exceeded (`rate_limited`, with `Retry-After`)
        or daily report quota exhausted (`quota_exceeded`).
      headers:
        Retry-After:
          $ref: "#/components/headers/RetryAfter"
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
          examples:
            rateLimited:
              summary: Per-minute rate limit
              value:
                type: https://www.clara.city/docs/errors#rate_limited
                code: rate_limited
                title: Rate limit exceeded
                status: 429
                detail: "rate limit of 100 requests per minute exceeded"
                request_id: 7c1f4c2a-0f3e-4d2b-9a6e-5b8d0c1e2f3a
            quotaExceeded:
              summary: Daily report quota
              value:
                type: https://www.clara.city/docs/errors#quota_exceeded
                code: quota_exceeded
                title: Daily report quota exceeded
                status: 429
                detail: "daily quota of 100 reports exceeded"
                request_id: 7c1f4c2a-0f3e-4d2b-9a6e-5b8d0c1e2f3a
    UpstreamError:
      description: An upstream dependency (geocoder or compute back end) failed.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
          example:
            type: https://www.clara.city/docs/errors#upstream_error
            code: upstream_error
            title: Upstream error
            status: 502
            detail: "upstream service returned an error"
            request_id: 7c1f4c2a-0f3e-4d2b-9a6e-5b8d0c1e2f3a
    UpstreamTimeout:
      description: The compute back end did not respond in time.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
          example:
            type: https://www.clara.city/docs/errors#upstream_timeout
            code: upstream_timeout
            title: Upstream timeout
            status: 504
            detail: "upstream service timed out"
            request_id: 7c1f4c2a-0f3e-4d2b-9a6e-5b8d0c1e2f3a

  schemas:
    Health:
      type: object
      required: [service, status, version, time]
      properties:
        service:
          type: string
          const: clara-api
        status:
          type: string
          const: ok
        version:
          type: string
          example: 1.0.0
        time:
          type: string
          format: date-time
          description: Current server time, ISO 8601 UTC.

    Problem:
      type: object
      description: RFC 9457 problem details document.
      required: [type, code, title, status, detail, request_id]
      properties:
        type:
          type: string
          format: uri
          description: >
            Link to the error's documentation:
            `https://www.clara.city/docs/errors#<code>`.
        code:
          type: string
          description: Machine-readable error code. Branch on this field.
          enum:
            - validation_error
            - unauthorized
            - forbidden
            - not_found
            - outside_coverage
            - product_unavailable
            - rate_limited
            - quota_exceeded
            - upstream_error
            - upstream_timeout
        title:
          type: string
          description: Short human-readable summary of the error class.
        status:
          type: integer
          description: HTTP status code, duplicated in the body.
        detail:
          type: string
          description: Human-readable explanation specific to this occurrence.
        request_id:
          type: string
          format: uuid
          description: Correlation id, identical to the `X-Request-Id` header.

    CityRef:
      type: object
      description: >
        Which city answered. Present on data responses when the location
        falls inside a covered city; absent for nationwide answers.
      required: [id, name, country]
      properties:
        id:
          type: string
          example: brussels
        name:
          type: string
          example: Brussels
        country:
          type: string
          description: ISO 3166-1 alpha-2 country code.
          example: BE

    CityIndex:
      type: object
      required: [generated_utc, cities]
      properties:
        generated_utc:
          type: string
          format: date-time
        cities:
          type: array
          items:
            $ref: "#/components/schemas/CityEntry"

    CityEntry:
      type: object
      required: [id, name, country, timezone, bbox, products]
      properties:
        id:
          type: string
          description: City identifier, as used in URLs.
          example: brussels
        name:
          type: string
          example: Brussels
        country:
          type: string
          description: ISO 3166-1 alpha-2 country code.
          example: BE
        timezone:
          type: string
          description: IANA timezone name.
          example: Europe/Brussels
        bbox:
          type: array
          description: "[west, south, east, north] in decimal degrees."
          items:
            type: number
          minItems: 4
          maxItems: 4
        metrics:
          type: array
          description: >
            Which environmental metrics this city models. Not uniform across
            cities. A metric your key holds but the city does not model
            answers 422 `product_unavailable`.
          items:
            type: string
            enum: [air, wind, thermal, noise]
        products:
          type: object
          description: Which products this city has.
          required: [geocode, historical, realtime, forecast, reports, map]
          properties:
            geocode: { type: boolean }
            historical: { type: boolean }
            realtime: { type: boolean }
            forecast: { type: boolean }
            reports: { type: boolean }
            map: { type: boolean }
        map:
          type: object
          description: Map product details. Present when `products.map` is true.
          required: [layers, cadence]
          properties:
            layers:
              type: array
              description: Map layers available for this city.
              items:
                type: string
                enum: [thermal, wind, air, buildings]
            forecast_hours:
              type: integer
              description: Approximate forecast horizon, hours.
              example: 48
            cadence:
              type: string
              description: How often live layers update.
              enum: [hourly]
        authorized:
          type: boolean
          description: >
            Present only when the request carried an API key: whether that
            key is scoped to this city.

    CityDetail:
      allOf:
        - $ref: "#/components/schemas/CityEntry"
        - type: object
          required: [boundary]
          properties:
            reference_year:
              type: integer
              description: Reference year of the historical dataset.
              example: 2025
            attribution:
              type: string
              example: CLARA by BuildWind
            boundary:
              type: object
              description: >
                GeoJSON Feature with a `Polygon` geometry: one linear ring
                of `[lon, lat]` points (WGS 84, longitude first; the first
                point is repeated as the last to close the ring).
              required: [type, geometry]
              properties:
                type:
                  type: string
                  const: Feature
                properties:
                  type: object
                geometry:
                  type: object
                  required: [type, coordinates]
                  properties:
                    type:
                      type: string
                      const: Polygon
                    coordinates:
                      type: array
                      description: One closed linear ring of `[lon, lat]` positions.
                      items:
                        type: array
                        items:
                          type: array
                          items:
                            type: number
                          minItems: 2
                          maxItems: 2

    View:
      type: object
      description: >
        One way of plotting a metric, with the scales valid for it. All views
        of a metric read the same vector tile, so switching between them is a
        repaint rather than a second request.
      required: [id, metric, name, fields, scales, default_scale]
      properties:
        id:
          type: string
          example: comfort-safety
        metric:
          type: string
          enum: [air, wind, thermal]
        name:
          type: string
          description: Display name, for a "what to show" control.
          example: Wind comfort and safety
        description:
          type: string
        fields:
          type: array
          description: Tile properties this view reads.
          items: { type: string }
          example: [Ucomfort, Ugust]
        scales:
          type: array
          description: Scale ids valid for this view, in display order.
          items: { type: string }
          example: [wind-nen8100, wind-cvd]
        default_scale:
          type: string
          example: wind-nen8100
        default:
          type: boolean
          description: Present and true on its metric's default view.

    ScaleSummary:
      type: object
      required: [id, metric, view, name, kind]
      properties:
        id:
          type: string
          example: wind-nen8100
        metric:
          type: string
          enum: [air, wind, thermal]
        view:
          type: string
          description: Which view of that metric this scale colours.
          example: comfort-safety
        name:
          type: string
          example: NEN 8100 colours
        authority:
          type: string
          description: Who defines this scale.
          example: CLARA (Lawson-based), NEN 8100 palette
        kind:
          type: string
          enum: [classes, gradient]
          description: >
            How to render it. `classes` is a swatch list; `gradient` is a
            colour bar, which matters when a scale has thirty bands.
        default:
          type: boolean
          description: Present and true on its view's default scale.

    Scale:
      allOf:
        - $ref: "#/components/schemas/ScaleSummary"
        - type: object
          required: [classes]
          properties:
            description:
              type: string
            fields:
              type: array
              description: Tile properties this scale reads.
              items: { type: string }
              example: [Ucomfort, Ugust]
            unit:
              type: string
              example: m/s
            interpolate:
              type: boolean
              description: >
                Gradient scales only. True: treat each class's `bounds.from`
                as a stop position and interpolate between them. False or
                absent: paint each band flat.
            relative:
              type: boolean
              description: >
                Gradient scales only. True: `bounds.from` is a POSITION IN THE
                RANGE, 0 to 1, not a value in the unit. Map the stops onto the
                `range` that `GET /v1/map/{city}` publishes for that layer at
                that hour. Used where the useful span moves with the season:
                a UTCI ramp wide enough for a January night leaves a July
                afternoon using a fraction of the palette.
            fallback_range:
              type: object
              description: >
                Relative scales only. The range to use when the catalog
                publishes none, so a client still draws something defensible.
                These are category boundaries, not fitted numbers.
              required: [min, max]
              properties:
                min: { type: number, example: 9 }
                max: { type: number, example: 46 }
            zero_means_missing:
              type: boolean
              description: >
                True when a value of zero means "no reading" rather than a
                clean one. NO2 is the case that needs it.
            classes:
              type: array
              description: The classes in display order.
              items:
                type: object
                required: [color]
                properties:
                  value:
                    type: [integer, "null"]
                    description: >
                      Numeric class value, for indexed scales. Where classes
                      carry a `when` rule, a feature takes the HIGHEST value
                      whose rule matches, so the order of this array does not
                      affect the outcome.
                  label:
                    type: string
                    description: Absent on gradient bands, which have no name.
                    example: Fairly good
                  color:
                    type: string
                    description: Hex colour used to render this class.
                    example: "#64F84A"
                  meaning:
                    type: string
                    description: >
                      What the class means for a person standing there, as
                      opposed to what triggers it. Use this in a legend: a
                      rule like "Ucomfort 4-6 and Ugust < 10" says nothing
                      about whether to put a bench there.
                    example: Avoid sitting
                  bounds:
                    type: object
                    description: >
                      Numeric bounds of the class. On a `classes` scale these
                      are exclusive at the lower edge and inclusive at the
                      upper (for example UTCI degrees C). On a `gradient`
                      scale `from` is the band start and is inclusive, or with
                      `interpolate` it is the stop position. `to` is null on
                      the last class, which is open-ended.
                    properties:
                      from: { type: [number, "null"] }
                      to: { type: [number, "null"] }
                  condition:
                    type: string
                    description: >
                      Human-readable condition, for classes decided by more
                      than one field.
                    example: Ucomfort > 8 or Ugust >= 10
                  when:
                    type: object
                    description: >
                      The same condition, machine-readable. Exactly one of
                      `all` or `any`; every entry is a numeric test against a
                      tile property. Build a paint expression from these
                      rather than reimplementing the classification from the
                      prose.
                    properties:
                      all:
                        type: array
                        items: { $ref: "#/components/schemas/RuleTest" }
                      any:
                        type: array
                        items: { $ref: "#/components/schemas/RuleTest" }
            no_data_color:
              type: string
              description: Colour for missing data, where defined.
              example: "#aaa4a4"
            thresholds:
              type: object
              description: >
                For index scales: per-pollutant thresholds. `bounds` are the
                upper concentration bounds (ug/m3, inclusive) for classes 1
                through 9; above the last bound is class 10. `field` is the
                tile property to read.
              additionalProperties:
                type: object
                required: [field, bounds]
                properties:
                  field:
                    type: string
                    example: C
                  zero_means_missing:
                    type: boolean
                  bounds:
                    type: array
                    items:
                      type: number

    RuleTest:
      type: object
      description: >
        One numeric test against a tile property. Every bound present must
        hold. Bounds are exclusive (`lt`, `gt`) or inclusive (`lte`, `gte`).
      required: [field]
      properties:
        field:
          type: string
          example: Ucomfort
        lt: { type: number }
        lte: { type: number }
        gt: { type: number }
        gte: { type: number }

    KeyInfo:
      type: object
      required: [label, type, cities, products, limits, created]
      properties:
        label:
          type: string
          description: Who or what this key was issued for.
          example: immoweb
        type:
          type: string
          description: sk is secret (server side), pk is publishable.
          enum: [sk, pk]
        cities:
          type: array
          description: City scope. `["*"]` means every city.
          items:
            type: string
        products:
          type: array
          description: Product scope. `["*"]` means every product.
          items:
            type: string
        metrics:
          type: array
          description: >
            Metric scope: which environmental metrics this key may read.
            `["*"]` means every metric, including ones added later. Products
            are how data is reached, metrics are which data; the two are
            independent. An empty list means the key has no metric
            entitlement and can read no data, which is a configuration
            problem: contact support.
          items:
            type: string
            enum: [air, wind, thermal, noise, "*"]
        horizons:
          type: array
          description: >
            Time scope for the MAP product: which blocks of
            `GET /v1/map/{city}` this key receives. `live` is the current
            hour, `forecast` every hour ahead, `["*"]` both and anything
            added later.

            It scopes the map only. The point products name their own time
            (`realtime`, `forecast`), so holding the forecast point API says
            nothing about whether this key sees forecast tiles, and the
            reverse. An empty list means no map data at all, which is a
            configuration problem: contact support.
          items:
            type: string
            enum: [live, forecast, "*"]
        limits:
          type: object
          required: [rate_per_min, reports_per_day]
          properties:
            rate_per_min:
              type: integer
              example: 100
            reports_per_day:
              type: integer
              example: 100
        origins:
          type: array
          description: >
            Origin allowlist, enforced for publishable keys when a browser
            sends an Origin header.
          items:
            type: string
        created:
          type: string
          format: date
        expires_at:
          type: [string, "null"]
          format: date-time

    GeocodeResponse:
      type: object
      required: [query, results]
      properties:
        query:
          type: string
          description: The query string as received.
        results:
          type: array
          description: Matches; may be empty.
          items:
            $ref: "#/components/schemas/GeocodeResult"

    GeocodeResult:
      type: object
      required: [address, lat, lon, relevance, covered]
      properties:
        address:
          type: string
          description: >
            Street and number, postcode and municipality, in a single
            language.
          example: "Chaussée de Waterloo 481, 1050 Ixelles"
        lat:
          type: number
          example: 50.82068
        lon:
          type: number
          example: 4.35821
        relevance:
          type: number
          minimum: 0
          maximum: 1
          description: Match confidence reported by the geocoder (0-1).
        covered:
          type: boolean
          description: >
            Whether this result lies inside a CLARA high-resolution zone: a
            strict point-in-polygon check against the boundary served at
            `GET /cities/{city}`.
          example: true
        city:
          type: string
          description: >
            Identifier of the covering city. Present when `covered` is
            true.
          example: brussels

    Location:
      type: object
      required: [address, lat, lon]
      properties:
        address:
          type: ["string", "null"]
          description: >
            Resolved address label in a single language; `null` when the
            request used raw coordinates.
          example: "Chaussée de Waterloo 481, 1050 Ixelles"
        lat:
          type: number
          description: Latitude used for the query, rounded to 5 decimals.
          example: 50.82068
        lon:
          type: number
          description: Longitude used for the query, rounded to 5 decimals.
          example: 4.35821

    StatsMeta:
      type: object
      required:
        [dataset, reference_year, sampling, n_cells, hours, generated_utc]
      properties:
        dataset:
          type: string
          description: >
            Which dataset answered. `brussels_extended` is the
            high-resolution central-Brussels model; `belgium_lowres` is the
            lower-resolution nationwide dataset used for every Belgian
            location outside that zone. Same quantities and scoring methods
            in both.
          enum: [brussels_extended, belgium_lowres]
        resolution_m:
          type: number
          description: >
            Grid resolution of the dataset that answered, metres. Always
            present for city datasets; may be absent on nationwide answers.
          example: 8
        reference_year:
          type: integer
          example: 2025
        sampling:
          type: string
          description: >
            How the location was sampled. `point`: the location sits in a
            grid cell and that cell alone was used. `nearest_outdoor`: the
            location is inside a building, so the outdoor cells at the
            nearest distance plus half a cell were used, and `distance_m`
            reports that distance. `radius`: the caller supplied an explicit
            radius, echoed in `radius_m`.
          enum: [point, nearest_outdoor, radius]
          example: nearest_outdoor
        distance_m:
          type: number
          description: >
            Only for `sampling: nearest_outdoor`: how far the nearest
            outdoor cell sits from the requested location, in metres.
          example: 7.5
        radius_m:
          type: number
          description: >
            Only when the caller supplied a radius: the radius applied, in
            metres.
          example: 10
        metrics:
          type: array
          description: >
            The metrics this response carries: your key's metric scope
            intersected with what you requested and what the city models.
            Self-describing, so you never have to infer scope from which
            blocks are present.
          items:
            type: string
          example: [air, wind, thermal]
        n_cells:
          type: integer
          description: Number of outdoor grid cells averaged.
          example: 3
        hours:
          type: integer
          description: Hourly records in the reference year.
          example: 8760
        generated_utc:
          type: string
          format: date-time
          description: When the statistics were computed (ISO 8601 UTC).

    AirStats:
      type: object
      description: >
        Air quality statistics on the BelAQI scale (see
        `GET /scales/air-belaqi`). The hourly BelAQI value is the worst of NO2,
        PM2.5 and PM10 per cell, averaged over the sampled cells, rounded
        half-up and clamped to 1-10. "Good air" means BelAQI class 1-4. The
        day/night split uses local hours 08:00-20:00.
      required:
        - hours_graded
        - annual_mean_ugm3
        - pollutant_scores
        - limiting_pollutant
        - limiting_pollutants
        - distribution_pct
        - grouped_pct
        - good_pct
        - day_good_pct
        - night_good_pct
        - monthly_good_pct
        - best_month
        - worst_month
        - dominant_pollutant
        - pollutant_share_pct
        - score
        - label
      properties:
        rating:
          type: number
          minimum: 0
          maximum: 10
          description: >
            The primary air number, no category label by design: the share
            of graded hours with hourly worst-pollutant BelAQI class 4
            ("fairly good") or better, divided by 10, so 7.9 means 79% of
            the year's hours. Linear, boundary published by IRCEL. The
            WHO-anchored `score` below is the methodological companion.
          example: 7.3
        hours_graded:
          type: integer
          description: Hours with valid AQI data (hours without data are excluded).
          example: 8760
        annual_mean_ugm3:
          type: object
          description: >
            Estimated yearly average concentration per pollutant (ug/m3, 1
            decimal), the scored input. Reconstructed from the hourly BelAQI
            classes: each hour contributes the midpoint of its class's
            concentration band, the open top class its lower bound times
            1.25. A pollutant with no data is null.
          required: [no2, pm25, pm10]
          properties:
            no2: { type: [number, "null"] }
            pm25: { type: [number, "null"] }
            pm10: { type: [number, "null"] }
        pollutant_scores:
          type: object
          description: >
            Per-pollutant WHO-ladder subscore (0-10, 1 decimal). The air
            score is the lowest of the three.
          required: [no2, pm25, pm10]
          properties:
            no2: { type: [number, "null"] }
            pm25: { type: [number, "null"] }
            pm10: { type: [number, "null"] }
        limiting_pollutants:
          type: array
          description: >
            Every pollutant sitting on the lowest subscore, i.e. the ones
            that set the score. Usually one; ordered NO2, PM2.5, PM10.
          items:
            type: string
            enum: [no2, pm25, pm10]
          example: [no2]
        limiting_pollutant:
          type: [string, "null"]
          description: First entry of limiting_pollutants, kept as a convenience.
          enum: [no2, pm25, pm10, null]
          example: no2
        distribution_pct:
          type: object
          description: >
            Share of graded hours per BelAQI class (percent). Keys are the
            ten BelAQI class labels.
          additionalProperties:
            type: number
        grouped_pct:
          type: object
          description: >
            Same distribution grouped into the four scoring bands: g12
            (classes 1-2), g34 (3-4), g56 (5-6), g710 (7-10).
          required: [g12, g34, g56, g710]
          properties:
            g12: { type: number }
            g34: { type: number }
            g56: { type: number }
            g710: { type: number }
        good_pct:
          type: number
          description: Share of graded hours with BelAQI 1-4 (percent).
          example: 72.7
        day_good_pct:
          type: number
          description: Good-air share during local hours 08:00-20:00 (percent).
        night_good_pct:
          type: number
          description: Good-air share during local hours 20:00-08:00 (percent).
        monthly_good_pct:
          type: object
          description: Good-air share per month; keys are month numbers "1"-"12".
          additionalProperties:
            type: number
        best_month:
          type: integer
          minimum: 1
          maximum: 12
          description: Month with the highest good-air share.
        worst_month:
          type: integer
          minimum: 1
          maximum: 12
          description: Month with the lowest good-air share.
        dominant_pollutant:
          type: string
          enum: [NO2, PM25, PM10]
          description: Pollutant most often responsible for the hourly BelAQI.
        pollutant_share_pct:
          type: object
          description: Share of graded hours each pollutant dominated (percent).
          required: [NO2, PM25, PM10]
          properties:
            NO2: { type: number }
            PM25: { type: number }
            PM10: { type: number }
        score:
          type: number
          minimum: 0
          maximum: 10
          description: >
            CLARA air quality score 0-10 anchored on the WHO global air
            quality guidelines (2021). Each pollutant's yearly average
            concentration is read on its own WHO ladder: the guideline
            level sits at 9.0 for every pollutant, and the interim targets
            sit by WHO label, the same for every pollutant (IT4 7.5, IT3
            6.5, IT2 3.0, IT1 0.0); 10 at zero concentration, linear
            between anchors, clamped at IT1. The score is the lowest of the
            per-pollutant subscores: the worst pollutant decides, mirroring
            BelAQI's own max-of-sub-indices rule.
          example: 6.8
        label:
          $ref: "#/components/schemas/ScoreLabel"
        benchmark:
          $ref: "#/components/schemas/Benchmark"

    Benchmark:
      type: object
      description: >
        Optional city-centre reference: mean good-air share over
        pre-computed city-centre locations. Omitted when benchmark data is
        unavailable.
      required: [description, n_locations, mean_good_pct, locations]
      properties:
        description:
          type: string
          example: Brussels city-centre reference locations
        n_locations:
          type: integer
          example: 5
        mean_good_pct:
          type: number
          example: 70.9
        locations:
          type: object
          description: Good-air share per reference location (percent).
          additionalProperties:
            type: number

    WindStats:
      type: object
      description: >
        Pedestrian wind comfort statistics. Each hour is assigned the worse
        of the comfort-wind class (from the mean pedestrian-level wind
        speed Ucomfort) and the gust class (from Ugust). Class definitions
        at `GET /scales?metric=wind`.
      required:
        - distribution_pct
        - calm_pct
        - exceedance_pct
        - mean_ucomfort_ms
        - p95_ucomfort_ms
        - max_gust_ms
        - monthly_mean_ucomfort_ms
        - score
        - label
      properties:
        rating:
          type: number
          minimum: 0
          maximum: 10
          description: >
            The primary wind number, no category label: the minimum of the
            anchored `score` and a worst-moment ladder that reads the
            year's windiest hour of the effective speed
            eff = max(Ucomfort, Ugust/1.85) on the City Lawson thresholds
            (2.5 at 9, 4 at 7.5, 6 at 6.5, 8 at 5, 15 at 3, 0 at 25 m/s).
          example: 8.6
        distribution_pct:
          type: object
          description: >
            Share of hours per wind comfort class (percent). Keys are the
            five class labels.
          additionalProperties:
            type: number
        calm_pct:
          type: number
          description: Share of hours in class 1, Calm (percent).
          example: 96.2
        eff_mean_ms:
          type: number
          description: Annual mean of the hourly effective speed (m/s).
          example: 0.55
        worst_moment_ms:
          type: number
          description: >
            The year's windiest hour as an effective speed (m/s, 1
            decimal), the rating's acute input.
          example: 4.9
        worst_moment_hours:
          type: integer
          description: >
            Hours of the year with effective speed within 0.5 m/s of the
            worst moment (always at least 1).
          example: 6
        exceedance_pct:
          type: object
          description: >
            Cumulative share of hours above each wind comfort threshold
            (percent, 3 decimals): above_frequent_sitting (Ucomfort at or
            above 2.5 m/s, the City Lawson level for frequent outdoor
            sitting), slightly_windy_or_worse (class 2 or worse),
            windy_or_worse (class 3 or worse), very_windy_or_worse (class 4
            or worse) and danger (class 5). These are the exceedances the
            wind score measures against annual allowances of 5, 5, 5, 5 and
            0.022 percent respectively. above_frequent_sitting is a score
            input only: it never changes the displayed comfort class.
          required:
            - above_frequent_sitting
            - slightly_windy_or_worse
            - windy_or_worse
            - very_windy_or_worse
            - danger
          properties:
            above_frequent_sitting: { type: number }
            slightly_windy_or_worse: { type: number }
            windy_or_worse: { type: number }
            very_windy_or_worse: { type: number }
            danger: { type: number }
        mean_ucomfort_ms:
          type: number
          description: Annual mean pedestrian-level wind speed (m/s).
          example: 2.41
        p95_ucomfort_ms:
          type: number
          description: 95th percentile of the pedestrian-level wind speed (m/s).
          example: 5.87
        max_gust_ms:
          type: number
          description: Maximum gust speed over the year (m/s).
          example: 18.32
        monthly_mean_ucomfort_ms:
          type: object
          description: >
            Mean pedestrian-level wind speed per month (m/s); keys are
            month numbers "1"-"12".
          additionalProperties:
            type: number
        score:
          type: number
          minimum: 0
          maximum: 10
          description: >
            CLARA wind comfort score 0-10 (City of London / Lawson
            exceedance method): score = 10 x 0.90^gs x 0.75^g2 x (13/15)^g3
            x (10/13)^g4 x 0.60^g5, with g_i = log2(1 + P_i / T_i), P_i the
            cumulative exceedances from exceedance_pct (gs from
            above_frequent_sitting) and T = 5 / 5 / 5 / 5 / 0.022 percent
            the annual allowances. Each factor is a score-band floor
            divided by the floor above, so exactly using an allowance lands
            the score on a band floor (9.0, 7.5, 6.5, 5.0, 3.0) and
            exceeding any allowance excludes that band and every band above
            it.
          example: 7.6
        label:
          $ref: "#/components/schemas/ScoreLabel"

    ThermalStats:
      type: object
      description: >
        Thermal comfort statistics from the hourly Universal Thermal
        Climate Index (UTCI, degrees C) of the reference year. A
        comfortable hour is one in the UTCI "no thermal stress" class,
        9-26 degrees C (see `GET /scales/thermal-utci`). Omitted from the response
        when the dataset carries no UTCI data.
      required:
        - seasonal_acceptable_pct
        - limiting_season
        - limiting_seasons
        - summer_day_comfort_pct
        - strong_heat_stress_days
        - tropical_nights
        - comfort_pct
        - mean_utci_c
        - min_utci_c
        - max_utci_c
        - monthly_mean_utci_c
        - score
        - label
      properties:
        rating:
          type: [number, "null"]
          minimum: 0
          maximum: 10
          description: >
            The primary thermal number, no category label: the comfortable
            share (9-26 degrees C UTCI, daytime 08:00-20:00) of the weakest
            outdoor season (summer, spring or autumn), divided by 10.
            Winter is reported in seasonal_comfort_pct but never rated.
          example: 5.7
        seasonal_comfort_pct:
          type: object
          description: >
            Share of each season's daytime hours with UTCI in the 9-26
            degrees C no-thermal-stress band (percent, 1 decimal), the
            rating's input, all four seasons for display.
          properties:
            spring: { type: [number, "null"] }
            summer: { type: [number, "null"] }
            autumn: { type: [number, "null"] }
            winter: { type: [number, "null"] }
        rating_limiting_seasons:
          type: array
          items: { type: string, enum: [summer, spring, autumn] }
          description: >
            Outdoor seasons tied on the weakest comfortable share, in
            reporting-importance order (summer first).
        rating_limiting_season:
          type: [string, "null"]
          description: First entry of rating_limiting_seasons.
        seasonal_acceptable_pct:
          type: object
          description: >
            Share of each meteorological season's daytime hours
            (08:00-20:00 local) with an acceptable UTCI of 0-32 degrees C
            (percent, 1 decimal). Seasons are spring (Mar-May), summer
            (Jun-Aug), autumn (Sep-Nov) and winter (Jan, Feb and Dec of the
            reference year). These four shares are the only inputs to the
            thermal score. A season with no hours in the dataset is null
            and is skipped by the score.
          required: [spring, summer, autumn, winter]
          properties:
            spring: { type: [number, "null"] }
            summer: { type: [number, "null"] }
            autumn: { type: [number, "null"] }
            winter: { type: [number, "null"] }
        limiting_season:
          type: [string, "null"]
          description: First entry of limiting_seasons, kept as a convenience.
          enum: [spring, summer, autumn, winter, null]
          example: winter
        limiting_seasons:
          type: array
          description: >
            Every season sitting on the lowest subscore, i.e. all the
            seasons that set the score. Ordered by reporting importance:
            summer, winter, spring, autumn.
          items:
            type: string
            enum: [spring, summer, autumn, winter]
          example: [winter]
        summer_day_comfort_pct:
          type: number
          description: >
            Descriptive only, not scored. Share of summer daytime hours
            (June-August, 08:00-20:00 local) with UTCI in the comfort band
            9-26 degrees C (percent). This is the narrower "no thermal
            stress" band, not the 0-32 band the score uses, so it is not
            comparable with seasonal_acceptable_pct.summer.
          example: 67.0
        strong_heat_stress_days:
          type: integer
          description: >
            Descriptive only, not scored. Days whose maximum UTCI exceeds
            32 degrees C, the UTCI strong heat stress threshold.
          example: 15
        tropical_nights:
          type: integer
          description: >
            Descriptive only, not scored. UTCI-based warm nights: nights
            (20:00-08:00 local, assigned to the evening's date) whose
            minimum UTCI stays above 20 degrees C.
          example: 8
        summer_night_mean_utci_c:
          type: number
          description: >
            Mean UTCI over the summer night hours (June-August, 20:00-08:00
            local) at this location.
          example: 17.2
        summer_night_vs_city_c:
          type: number
          description: >
            How much warmer (positive) or cooler (negative) summer nights
            are here than in the coverage area as a whole. Model-derived.
            Absent when that reference has not been generated for the
            dataset.
          example: 0.8
        comfort_pct:
          type: number
          description: >
            Descriptive only, not scored. Share of all hours of the year
            with UTCI in the comfort band 9-26 degrees C (percent).
          example: 51.9
        mean_utci_c:
          type: number
          description: Annual mean UTCI (degrees C).
          example: 10.8
        min_utci_c:
          type: number
          description: Minimum hourly UTCI over the year (degrees C).
          example: -14.2
        max_utci_c:
          type: number
          description: Maximum hourly UTCI over the year (degrees C).
          example: 36.8
        monthly_mean_utci_c:
          type: object
          description: >
            Mean UTCI per month (degrees C); keys are month numbers
            "1"-"12".
          additionalProperties:
            type: number
        score:
          type: number
          minimum: 0
          maximum: 10
          description: >
            CLARA thermal comfort score 0-10 (City of London Thermal
            Comfort Guidelines season-and-percentage structure): each share
            in seasonal_acceptable_pct becomes a subscore by linear
            interpolation through that season's anchors, pinned to the
            CLARA band floors: winter (25%, 3.0), (50%, 5.0), (70%, 6.5),
            (90%, 7.5); spring, summer and autumn (50%, 5.0), (90%, 7.5);
            every season (0%, 0.0) and (100%, 10.0). The score is the
            lowest of the four subscores.
          example: 4.4
        label:
          $ref: "#/components/schemas/ScoreLabel"

    ScoreLabel:
      type: string
      description: >
        Consumer label for a 0-10 score: Excellent at 9.0 or above, Very
        good at 7.5, Good at 6.5, Moderate at 5.0, Poor at 3.0, Bad below
        3.0.
      enum: [Excellent, Very good, Good, Moderate, Poor, Bad]

    StatsResponse:
      type: object
      description: >
        The `air`, `wind` and `thermal` blocks are all present by default;
        any block not selected by the `metrics` parameter is omitted. The
        `thermal` block is additionally omitted when the dataset carries no
        UTCI data. `city` is present when the location falls inside a
        covered city.
      required: [location, meta]
      properties:
        city:
          $ref: "#/components/schemas/CityRef"
        location:
          $ref: "#/components/schemas/Location"
        meta:
          $ref: "#/components/schemas/StatsMeta"
        air:
          $ref: "#/components/schemas/AirStats"
        wind:
          $ref: "#/components/schemas/WindStats"
        thermal:
          $ref: "#/components/schemas/ThermalStats"

    RealtimeMeta:
      type: object
      required: [dataset, radius_m, hours, retrieved_utc]
      properties:
        dataset:
          type: string
          description: Which dataset answered.
          example: brussels_extended
        resolution_m:
          type: number
          description: Grid resolution of the dataset that answered, metres.
          example: 8
        radius_m:
          type: number
          description: Averaging radius applied, in metres.
          example: 10
        metrics:
          type: array
          description: >
            The metrics these records carry. Fields belonging to a metric
            outside your key's scope are omitted from every record.
          items:
            type: string
          example: [air, wind, thermal]
        hours:
          type: integer
          description: Number of records in the `hourly` array.
          example: 24
        retrieved_utc:
          type: string
          format: date-time
          description: >
            When this response was assembled (ISO 8601 UTC). Because
            responses are cached for 5 minutes, the hourly payload itself
            may be up to 5 minutes older.

    RealtimeRecord:
      type: object
      description: >
        One hourly record from the model, passed through verbatim. Records
        may carry additional fields as the model evolves.
      properties:
        hour:
          type: string
          description: >
            Hour of the record as an ISO 8601 UTC timestamp string.
        timestamp:
          type: string
          description: >
            Epoch timestamp of the record in milliseconds, as a string.
        c:
          type: number
          description: NO2 concentration (ug/m3).
        ugust:
          type: number
          description: Pedestrian-level gust wind speed (m/s).
        ucomfort:
          type: number
          description: Pedestrian-level mean comfort wind speed (m/s).
        utci:
          type: number
          description: Universal Thermal Climate Index (degrees C).
        c_pm10:
          type: number
          description: PM10 concentration (ug/m3).
        c_pm25:
          type: number
          description: PM2.5 concentration (ug/m3).
        c_o3:
          type: number
          description: Ozone concentration (ug/m3).
      additionalProperties: true

    RealtimeResponse:
      type: object
      description: >
        `city` is present when the location falls inside a covered city.
      required: [location, meta, hourly]
      properties:
        city:
          $ref: "#/components/schemas/CityRef"
        location:
          $ref: "#/components/schemas/Location"
        meta:
          $ref: "#/components/schemas/RealtimeMeta"
        hourly:
          type: array
          minItems: 1
          description: Hourly records from the model, verbatim.
          items:
            $ref: "#/components/schemas/RealtimeRecord"

    ReportRequest:
      type: object
      description: >
        Provide either `address` or both `lat` and `lon`.
      properties:
        address:
          type: string
          maxLength: 200
          description: Free-text address inside a covered area.
          example: "Chaussée de Waterloo 481, 1050 Ixelles"
        lat:
          type: number
          description: Latitude in decimal degrees (WGS 84).
          example: 50.82068
        lon:
          type: number
          description: Longitude in decimal degrees (WGS 84).
          example: 4.35821
        radius:
          type: number
          minimum: 5
          maximum: 50
          default: 10
          description: Averaging radius in metres.
        lang:
          type: string
          enum: [en, fr, nl]
          default: en
          description: Report language.

    ReportAccepted:
      type: object
      required: [job_id, status, stage, links]
      properties:
        job_id:
          type: string
          format: uuid
        status:
          type: string
          const: processing
        stage:
          type: string
          const: queued
        links:
          type: object
          required: [status]
          properties:
            status:
              type: string
              description: Relative URL to poll for job progress.
              example: /v1/reports/3f8a2c1e-9b4d-4e6a-8c2f-1d5e7a9b0c3d

    ReportStatus:
      oneOf:
        - $ref: "#/components/schemas/ReportProcessing"
        - $ref: "#/components/schemas/ReportCompleted"
        - $ref: "#/components/schemas/ReportFailed"
      discriminator:
        propertyName: status
        mapping:
          processing: "#/components/schemas/ReportProcessing"
          completed: "#/components/schemas/ReportCompleted"
          failed: "#/components/schemas/ReportFailed"

    ReportProcessing:
      type: object
      required: [job_id, status, stage]
      properties:
        job_id:
          type: string
          format: uuid
        status:
          type: string
          const: processing
        stage:
          type: string
          enum: [queued, running, delivering]

    ReportCompleted:
      type: object
      required: [job_id, status, address, lang, files, expires_in_days]
      properties:
        job_id:
          type: string
          format: uuid
        status:
          type: string
          const: completed
        address:
          type: string
          example: "Chaussée de Waterloo 481, 1050 Ixelles"
        lang:
          type: string
          enum: [en, fr, nl]
        files:
          type: object
          required: [html]
          properties:
            html:
              $ref: "#/components/schemas/ReportFileLink"
            pdf:
              $ref: "#/components/schemas/ReportFileLink"
        pdf_pending:
          type: boolean
          const: true
          description: >
            Present (true) only when the PDF is not ready yet; `files.pdf`
            is then omitted and the next status poll retries the rendering.
        expires_in_days:
          type: integer
          const: 30
          description: Retention period of the generated files.

    ReportFileLink:
      type: object
      required: [url]
      properties:
        url:
          type: string
          description: Relative, authenticated download URL.
          example: /v1/reports/3f8a2c1e-9b4d-4e6a-8c2f-1d5e7a9b0c3d/download?format=pdf

    ReportFailed:
      type: object
      required: [job_id, status, detail]
      properties:
        job_id:
          type: string
          format: uuid
        status:
          type: string
          const: failed
        detail:
          type: string
          description: Short human-readable failure reason.
