CLARA docs

API reference

Every CLARA endpoint on one page: parameters, response fields, an abridged example and the errors each one can return. CLARA serves hyper-local air quality, wind comfort and danger, thermal comfort and noise, modelled at up to 1×1 m, as JSON over HTTPS.

The base URL for everything below is https://api.clara.city/v1. Responses are JSON (application/json), errors are RFC 9457 problem documents (application/problem+json), and all timestamps are ISO 8601 UTC. The machine-readable contract lives in two OpenAPI files: the main API and the Maps API. If prose and specification ever disagree, the specification wins.

Data responses share one envelope: city (which city answered), location (the resolved address and the coordinates actually used) and meta (which dataset answered and how the location was sampled). The getting started guide walks through reading one.

Authentication

Every data endpoint takes an API key as a bearer token, in either header:

Authorization: Bearer YOUR_CLARA_KEY
X-API-Key: YOUR_CLARA_KEY

Secret keys (clara_sk_) stay on your server; publishable keys (clara_pk_) may sit in a browser page and are checked against the origins registered on them. Four endpoints need no key at all: /health, /cities (including /cities/{city}) and /scales. Map tiles use signed URLs instead of a key, so map libraries can fetch them without custom headers.

Keys are scoped to cities and products, and rate limited per minute with a separate daily report quota. GET /v1/key shows a key's exact scope. How to get a key and how origin registration works: the getting started guide.

GET/v1/health

No key required. Not rate limited.

Confirms the service is up and reports the running API version. Useful as a monitoring target or a first connectivity check.

Response fields

FieldMeaning
serviceAlways clara-api.
statusAlways ok when you get a 200.
versionThe API version currently serving.
timeCurrent server time, ISO 8601 UTC.

Example

{
  "service": "clara-api",
  "status": "ok",
  "version": "1.0.0",
  "time": "2026-08-20T10:15:42Z"
}

GET/v1/cities

No key required.

The machine-readable city index: every city CLARA covers, with its bounding box and the products it has. Called with a key, each entry also carries authorized, whether that key is scoped to the city. The response is cacheable for an hour (Cache-Control: public, max-age=3600). Called with a key the lifetime is the same but the caching turns private (Cache-Control: private, max-age=3600, with Vary: Authorization, X-API-Key), because the answer then depends on the key.

Parameters

NameInRequiredMeaning
productqueryno Only return cities that have this product. One of geocode, historical, realtime, forecast, reports, map.

Response fields

FieldMeaning
generated_utcWhen the index was assembled.
cities[].idCity identifier, lowercase, as used in URLs.
cities[].nameDisplay name.
cities[].countryISO 3166-1 alpha-2 country code.
cities[].timezoneIANA timezone name.
cities[].bboxBounding box, [west, south, east, north] in decimal degrees.
cities[].productsOne boolean per product: geocode, historical, realtime, forecast, reports, map.
cities[].metricsWhich environmental metrics the city models, e.g. ["air", "wind", "thermal", "noise"] for Brussels and ["wind", "thermal"] for Ravenna.
cities[].mapMap product details, present when products.map is true: layers (the available layer ids), forecast_hours (the upper bound of the forecast horizon, not a promise of that many steps; the steps actually published are the forecast array of GET /v1/map/{city}) and cadence (hourly).
cities[].authorizedOnly when the request carried a key: whether that key is scoped to this city.

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

GET/v1/cities/{city}

No key required.

Everything the index reports for one city, plus the exact coverage boundary as a GeoJSON Feature with a Polygon geometry ([lon, lat] coordinate order). Draw it on a map, or use it to pre-filter locations before spending data requests. Where the next expansion of a city has been announced, a second Feature, planned_boundary, gives that coming zone, so a client can draw it alongside the current one. Brussels carries the Capital Region there. Cacheable for an hour; it changes only when coverage changes. Called with a key the caching turns private (Cache-Control: private, max-age=3600, with Vary: Authorization, X-API-Key); the lifetime is unchanged.

Parameters

NameInRequiredMeaning
citypathyes City identifier, lowercase, from GET /v1/cities.

Response fields

FieldMeaning
All the fields of a /v1/cities entry, plus:
reference_yearReference year of the historical dataset.
attributionAttribution string to display alongside the data.
boundaryGeoJSON Feature: one closed linear ring of [lon, lat] points (WGS 84, longitude first).
planned_boundaryThe announced next zone, in the same shape as boundary. Present only where an expansion has been announced: Brussels carries the Capital Region, Ravenna carries nothing.

Example

{
  "id": "brussels",
  "name": "Brussels",
  "country": "BE",
  "timezone": "Europe/Brussels",
  "bbox": [4.30421, 50.79655, 4.41729, 50.87949],
  "products": { "geocode": true, "historical": true, "...": "..." },
  "reference_year": 2025,
  "attribution": "CLARA by BuildWind",
  "boundary": {
    "type": "Feature",
    "properties": { "name": "Brussels coverage boundary" },
    "geometry": {
      "type": "Polygon",
      "coordinates": [[[4.31282, 50.87911], [4.31355, 50.87801], "..."]]
    }
  },
  "planned_boundary": {
    "type": "Feature",
    "properties": { "name": "Brussels planned coverage boundary" },
    "geometry": {
      "type": "Polygon",
      "coordinates": [[[4.28247, 50.83785], [4.27765, 50.83785], "..."]]
    }
  }
}

Errors: not_found (404).

GET/v1/scales

No key required.

What each metric can be plotted as, and how to colour it. A view is what to plot: wind can be drawn as the mean pedestrian speed, as the gusts, or as the combined comfort and danger classes; air as the index or as one pollutant; thermal as the UTCI stress categories (utci) or as the same reading drawn as a temperature (utci-temp, coloured by thermal-auto by default: Cold, Neutral or Hot chosen for each hour, see below). A scale is how to colour one view. These definitions sit behind every coloured value CLARA renders, published so clients never hardcode them. Cacheable for an hour; a scale changes only when its issuing authority revises it.

Where the scales come from

The Belgian Air Quality Index (BelAQI) provides a comprehensive measure of air quality. It is calculated based on the concentration of several key pollutants: PM2.5, PM10, NO2 and O3. A higher BelAQI value indicates poorer air quality and greater risk for health. The index ranges from 1 (Excellent) to 10 (Horrible). Learn more

The Wind Comfort Index assesses how comfortable the wind conditions are for outdoor activities. It is based on the City of London microclimatic guidelines, which provide a comfort rating for different activities from 1 (comfortable for all activities) to 5 (dangerous for all activities). Learn more

The Universal Thermal Climate Index (UTCI) is an international standard for assessing thermal comfort in outdoor environments. It considers air temperature, wind speed, humidity, and radiation to provide a comprehensive measure of how the environment feels to the human body. The index ranges from extreme cold stress to extreme heat stress. Learn more

Every scale served by this endpoint carries its source as reference, so a legend can link its own provenance without hardcoding a URL.

Parameters

NameInRequiredMeaning
metricqueryno Limit the answer to air, wind or thermal.

Response fields

FieldMeaning
views[].idWhat to plot, e.g. gust.
views[].fieldsTile properties this view reads.
views[].scalesScale ids valid for it, in display order.
views[].default_scaleWhich to use when the client has no preference.
scales[].idScale identifier for GET /v1/scales/{scale}.
scales[].viewThe view this scale colours.
scales[].kindclasses draws a swatch list, gradient a colour bar.
scales[].authorityWho defines the scale.
scales[].referenceAuthoritative public source describing the scale, for attribution and further reading.

Example

GET /v1/scales?metric=wind

{
  "views": [
    { "id": "comfort-danger", "metric": "wind", "name": "Wind comfort and danger",
      "fields": ["Ucomfort", "Ugust"],
      "field_labels": { "Ucomfort": "Wind", "Ugust": "Gust" },
      "scales": ["wind-comfort-standard", "wind-comfort-colour-blind"],
      "default_scale": "wind-comfort-standard", "default": true },
    { "id": "mean", "metric": "wind", "name": "Wind", "fields": ["Ucomfort"],
      "field_labels": { "Ucomfort": "Wind" },
      "scales": ["wind-mean-rainbow"], "default_scale": "wind-mean-rainbow" },
    { "id": "gust", "metric": "wind", "name": "Gust", "fields": ["Ugust"],
      "field_labels": { "Ugust": "Gust" },
      "scales": ["wind-gust-rainbow"], "default_scale": "wind-gust-rainbow" }
  ],
  "scales": [
    { "id": "wind-comfort-standard", "view": "comfort-danger", "kind": "classes",
      "name": "Standard", "default": true },
    { "id": "wind-comfort-colour-blind", "view": "comfort-danger", "kind": "classes",
      "name": "Colour-blind" },
    { "id": "wind-mean-rainbow", "view": "mean", "kind": "gradient",
      "name": "Standard", "default": true },
    { "id": "wind-gust-rainbow", "view": "gust", "kind": "gradient",
      "name": "Standard", "default": true }
  ]
}

GET/v1/scales/{scale}

No key required.

The full definition of one scale: classes in display order with labels, colours and bounds, so a legend and a map style come from one fetch. kind says how to consume it. Comfort classes also carry when, the machine-readable form of their condition, so the classification can be built rather than reimplemented from the prose. Index scales such as air-belaqi carry per-pollutant thresholds instead.

Parameters

NameInRequiredMeaning
scalepathyes One of wind-comfort-standard, wind-comfort-colour-blind, wind-mean-rainbow, wind-gust-rainbow, air-belaqi, air-no2, air-pm25, air-pm10, thermal-utci, thermal-auto, thermal-cold, thermal-neutral, thermal-hot, thermal-magma. List them with GET /v1/scales.

Response fields

FieldMeaning
id, metric, view, name, authority, kind, default As in the scale index.
descriptionWhat the scale measures and how its value is derived.
fieldsThe tile properties this scale reads, e.g. Ucomfort and Ugust.
unitUnit of the values it classifies. Absent on index scales, which are dimensionless.
interpolate Gradient scales only. True: each class's bounds.from is a stop position, interpolate between them. False or absent: paint each band flat.
zero_means_missing True when a value of zero means "no reading" rather than a clean one. NO2 is the case that needs it.
classes[] The classes in display order: value (numeric class, for indexed scales), label, color (hex), and bounds (from/to) where the scale is value-based. to is null on an open-ended last class.
classes[].condition Human-readable condition, on classes decided by more than one field.
classes[].when The same condition, machine-readable: exactly one of all or any, each holding numeric tests of the form { field, lt|lte|gt|gte }. A feature takes the highest value whose rule matches, so rules are self-contained and the order of the array does not affect the outcome. Build your paint expression from these rather than reimplementing the classification.
no_data_colorColour for missing data, where defined.
thresholds Index scales only, keyed by pollutant. Each entry carries field (the tile property to read), bounds (upper concentration bounds in µg/m3, inclusive, for classes 1 through 9; above the last bound is class 10) and optionally zero_means_missing. The classes and the bounds are IRCEL-CELINE's, published as the BelAQI index.

Example

GET /v1/scales/air-belaqi

{
  "id": "air-belaqi",
  "metric": "air",
  "view": "index",
  "name": "BelAQI air quality index",
  "authority": "IRCEL-CELINE (Belgium)",
  "description": "Belgian air quality index, 1 (Excellent) to 10 (Horrible). ...",
  "kind": "classes",
  "fields": ["C", "C_PM25", "C_PM10"],
  "default": true,
  "classes": [
    { "value": 1, "label": "Excellent", "color": "#012BF5" },
    { "value": 2, "label": "Very good", "color": "#2D9BF8" },
    { "...": "..." },
    { "value": 10, "label": "Horrible", "color": "#8E2695" }
  ],
  "no_data_color": "#aaa4a4",
  "thresholds": {
    "no2":  { "field": "C", "bounds": [10, 15, 20, 30, 40, 45, 50, 60, 75],
              "zero_means_missing": true },
    "pm25": { "field": "C_PM25", "bounds": [3.5, 7.5, 10, 15, 20, 35, 50, 60, 75] },
    "pm10": { "field": "C_PM10", "bounds": [10, 20, 35, 45, 60, 80, 95, 110, 140] }
  }
}

Errors: not_found (404).

GET/v1/key

Requires a key. Any product.

Echoes the presented key's label, type, all four scope axes, and limits. The first call to make after receiving a key: it confirms the key works and shows exactly what it can reach.

Response fields

FieldMeaning
labelWho or what the key was issued for.
typesk (secret, server side) or pk (publishable).
citiesCity scope. ["*"] means every city.
productsProduct scope. ["*"] means every product.
metricsMetric scope: which environmental metrics the key may read. ["*"] means every metric, current and future. An empty list is a configuration problem: the key can read no data.
horizonsTime scope for the map product: realtime, forecast, or ["*"] for both. It scopes the map alone; the point products carry their own time in their names.
coverageZone scope. ["*"], the default, is everything the city covers. ["high_res"] keeps the key inside the current high-resolution zone of Brussels, where GET /v1/geocode answers covered: high_res (the boundary of GET /v1/cities/brussels), at least 150 m from its edge. The part of the Region still in development (covered: high_res_in_development, the planned_boundary) stays out. A location outside the zone gets 403 outside_key_coverage on real-time, forecast, historical statistics and reports, whether sent as an address or as coordinates, and area statistics show only the areas inside the zone.
limitsrate_per_min (requests per minute) and reports_per_day (daily report quota).
originsOrigin allowlist, enforced for publishable keys when a browser sends an Origin header.
createdIssue date.
expires_atExpiry timestamp, or null for a key that does not expire.

Example

{
  "label": "your-company",
  "type": "sk",
  "cities": ["brussels"],
  "products": ["geocode", "historical", "realtime", "forecast", "reports"],
  "metrics": ["air", "wind", "thermal"],
  "horizons": ["realtime", "forecast"],
  "coverage": ["*"],
  "limits": { "rate_per_min": 100, "reports_per_day": 100 },
  "origins": [],
  "created": "2026-08-20",
  "expires_at": null
}

Errors: unauthorized (401), forbidden (403).

GET/v1/geocode

Requires a key with the geocode product.

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. Coordinates are returned only for candidates inside the CLARA service area, a covered city or Belgium; a candidate outside it comes back as its label with covered: false and nothing else. covered is graded, and means exactly "we have data there": high_res inside a city's current zone, high_res_in_development inside an announced expansion (the Brussels Capital Region), national_low_res elsewhere in Belgium, false beyond. results may be empty when nothing matches.

Parameters

NameInRequiredMeaning
qqueryyes Free-text address query.
limitqueryno Maximum number of results, 1 to 5. Default 5.
langqueryno Language of address: fr, nl or en. Default fr. In Brussels, where streets and municipalities have a French and a Dutch name, nl gives the Dutch form and fr or en the French one. Elsewhere an address keeps its one official form.

Response fields

FieldMeaning
queryThe query string as received.
results[].addressStreet and number, postcode and municipality, in a single language.
results[].lat, results[].lonCoordinates in decimal degrees (WGS 84): the point CLARA reads for the address (the building entrance when known).
results[].pointentrance or address.
results[].relevanceMatch confidence reported by the geocoder, 0 to 1.
results[].coveredWhat CLARA has at that point, in four grades. high_res: inside a city's current high-resolution zone, decided by a strict point-in-polygon check against the boundary at GET /v1/cities/{city}. high_res_in_development: inside an announced expansion of that zone, the planned_boundary of the same city. Every product of the city already answers there at high resolution, real-time and forecast included; a key limited to the high-resolution zone gets 403 outside_key_coverage there. national_low_res: elsewhere in Belgium, served by the nationwide low-resolution dataset. false: beyond all of that, and no coordinates come back.
results[].cityIdentifier of the covering city. Present when covered is high_res or high_res_in_development.

Example

{
  "query": "Chaussée de Waterloo 481, Ixelles",
  "results": [
    {
      "address": "Chaussée de Waterloo 481, 1050 Ixelles",
      "lat": 50.82068,
      "lon": 4.35821,
      "point": "address",
      "relevance": 1.0,
      "covered": "high_res",
      "city": "brussels"
    }
  ]
}

Errors: validation_error (400), unauthorized (401), forbidden (403), rate_limited (429), upstream_error (502).

GET/v1/historical/stats

Requires a key with the historical product.

The reference-year statistics for one location: air quality, wind comfort, thermal comfort and noise, each with a 0 to 10 rating and the full statistics behind it. Provide either address or both lat and lon; supplying both at once returns 400 validation_error. Coordinates are rounded to 6 decimals before processing.

Any location in Belgium is served. Every address in the Brussels Capital Region gets high-resolution data (brussels_extended or brussels_region), and the rest of Belgium is answered by the nationwide dataset. meta.dataset reports which one answered. 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, the nearest outdoor cell. Building-interior cells are never read. meta.sampling says which rule was used. Supplying radius averages deliberately over that area instead, with no widening; nationwide answers always use the cell containing the location and ignore radius.

The first request for a location computes its statistics from a year of hourly data and can take a few minutes. Every later request for the same (lat, lon, radius) triple answers instantly from cache; the X-Cache header says which happened. Set your client timeout generously on the first call.

Parameters

NameInRequiredMeaning
addressqueryno Free-text address; the top geocoding result is used. Alternative to lat and lon.
latqueryno Latitude in decimal degrees (WGS 84).
lonqueryno Longitude in decimal degrees (WGS 84).
radiusqueryno Optional averaging radius in metres, 5 to 50. Omit it (recommended) to get the value at the location. A radius that contains no outdoor cell returns 422 rather than a value from further away.
metricsqueryno Comma-separated subset of air,wind,thermal,noise, any order. Default: all blocks.
hoursqueryno Local hours to keep, START-END: 8-20 for the daytime, 7-10 for the morning, 22-6 across midnight. Every figure, ratings included, is then computed on those hours only. Default: every hour for air and wind, 08:00 to 20:00 for thermal comfort.

Hours window

With hours, the answer says which window it used in meta.hours_window (for example "08-20") and how many hours in meta.hours. A day count, such as hot days, counts a day when one of its window hours crosses the threshold; a window across midnight belongs to the day it starts on. Figures that need the fixed day and night split or a whole night (day_good_pct, night_good_pct, tropical_nights, the summer-night comparison) are null or left out when the window does not include them, and so is the city-centre air benchmark. Each location and window is computed once; the first request for a new window takes as long as a new location.

Response fields, envelope

FieldMeaning
cityWhich city answered: id, name, country. Present when the location falls inside a city's boundary (GET /v1/cities/{city}), absent elsewhere. meta.dataset says which dataset answered either way.
locationThe resolved address label (null for raw coordinates) plus lat and lon as used, rounded to 6 decimals.
meta.datasetWhich dataset answered: brussels_extended or brussels_region (high resolution, Brussels) or belgium_lowres (nationwide). Same quantities and scoring methods in all.
meta.reference_yearThe year the statistics describe.
meta.samplingpoint (the cell containing the location), nearest_outdoor (the location is inside a building; the nearest outdoor cell was used) or radius (an explicit radius was supplied).
meta.distance_mOnly for nearest_outdoor: how far the value came from, metres.
meta.radius_mOnly when a radius was supplied: the radius applied, metres.
meta.n_cellsNumber of outdoor grid cells averaged.
meta.hoursHourly records used: the whole reference year, or the hours of the window.
meta.hours_windowOnly with hours: the window applied, e.g. "08-20".
meta.generated_utcWhen the statistics were computed.

Response fields, air

Air quality on the BelAQI scale (GET /v1/scales/air-belaqi). The hourly value is the worst of NO2, PM2.5 and PM10 per cell, averaged over the sampled cells. "Good air" means BelAQI class 1 to 4.

FieldMeaning
ratingThe headline air number, 0 to 10: the share of graded hours at BelAQI class 4 ("fairly good") or better, divided by 10.
scoreCLARA air quality score, 0 to 10, anchored on the WHO 2021 global air quality guidelines. Each pollutant's yearly average is read on its own WHO ladder and the worst pollutant decides.
labelConsumer label for the score: Excellent, Very good, Good, Moderate, Poor or Bad.
hours_gradedHours with valid air quality data.
annual_mean_ugm3Estimated yearly average concentration per pollutant (no2, pm25, pm10), µg/m3; null when a pollutant has no data.
pollutant_scoresPer-pollutant WHO subscore, 0 to 10. The score is the lowest of the three.
limiting_pollutantsEvery pollutant sitting on the lowest subscore, ordered NO2, PM2.5, PM10.
limiting_pollutantFirst entry of limiting_pollutants, kept as a convenience.
distribution_pctShare of graded hours per BelAQI class, keyed by class label.
grouped_pctThe same distribution in four bands: g12, g34, g56, g710.
good_pctShare of graded hours at BelAQI 1 to 4, percent.
day_good_pct, night_good_pctGood-air share for local hours 08:00 to 20:00 and 20:00 to 08:00.
monthly_good_pctGood-air share per month; keys are month numbers "1" to "12".
monthly_bad_pctShare of graded hours at BelAQI 7 to 10, per month; keys "1" to "12".
best_month, worst_monthMonths with the highest and lowest good-air share.
dominant_pollutantPollutant most often responsible for the hourly index value.
pollutant_share_pctShare of graded hours each pollutant dominated.
benchmarkOptional city-centre reference: mean good-air share over pre-computed reference locations. Omitted when unavailable.

Response fields, wind

Pedestrian wind comfort. Each hour is assigned the worse of the comfort-wind class (from Ucomfort) and the gust class (from Ugust); class definitions at GET /v1/scales?metric=wind.

FieldMeaning
ratingThe headline wind number, 0 to 10: the minimum of score and a worst-moment ladder read from the year's windiest hour.
scoreCLARA wind comfort score, 0 to 10, City of London / Lawson exceedance method: each comfort threshold has an annual allowance, and exceeding one excludes that score band and every band above it.
labelConsumer label for the score.
distribution_pctShare of hours per wind comfort class, keyed by class label.
calm_pctShare of hours in class 1, Calm.
eff_mean_msAnnual mean of the hourly effective speed, m/s.
worst_moment_msThe year's windiest hour as an effective speed, m/s.
worst_moment_hoursHours of the year within 0.5 m/s of the worst moment (at least 1).
exceedance_pctCumulative share of hours above each comfort threshold: above_frequent_sitting, slightly_windy_or_worse, windy_or_worse, very_windy_or_worse, danger. These are the exceedances the score measures.
mean_ucomfort_msAnnual mean pedestrian-level wind speed, m/s.
p95_ucomfort_ms95th percentile of the pedestrian-level wind speed, m/s.
max_gust_msMaximum gust speed over the year, m/s.
max_ucomfort_msHighest hourly wind speed, m/s.
windy_hoursHours above calm: wind of 4 m/s or more, or gusts of 10 m/s or more.
monthly_max_gust_msStrongest gust per month, m/s; keys "1" to "12".
monthly_mean_ucomfort_msMean pedestrian-level wind speed per month; keys "1" to "12".

Response fields, thermal

Thermal comfort from the hourly Universal Thermal Climate Index (UTCI, degrees C). A comfortable hour sits in the "no thermal stress" band, 9 to 26 degrees C (GET /v1/scales/thermal-utci). The block is omitted when the dataset carries no UTCI data.

FieldMeaning
ratingThe headline thermal number, 0 to 10: the comfortable share (daytime, 08:00 to 20:00) of the weakest outdoor season, divided by 10. Winter is reported but never rated.
scoreCLARA thermal comfort score, 0 to 10: each season's acceptable share (0 to 32 degrees C UTCI, daytime) becomes a subscore through that season's anchors, and the lowest of the four decides.
labelConsumer label for the score.
seasonal_comfort_pctComfortable share (9 to 26 degrees C) of each season's daytime hours, all four seasons, the rating's input.
rating_limiting_seasons, rating_limiting_seasonOutdoor seasons tied on the weakest comfortable share; the first as a convenience.
seasonal_acceptable_pctAcceptable share (0 to 32 degrees C) of each season's daytime hours, the score's only inputs. A season absent from the dataset is null.
limiting_seasons, limiting_seasonSeasons sitting on the lowest subscore; the first as a convenience.
summer_day_comfort_pctDescriptive: share of summer daytime hours in the comfort band.
strong_heat_stress_daysDescriptive: days whose maximum UTCI exceeds 32 degrees C.
cold_stress_daysDescriptive: days whose daytime UTCI (or the window's) drops below 0 degrees C.
tropical_nightsDescriptive: nights (20:00 to 08:00 local) whose minimum UTCI stays above 20 degrees C. null with a window that does not cover the whole night.
summer_night_mean_utci_cMean UTCI over summer night hours at this location.
summer_night_vs_city_cHow much warmer (positive) or cooler (negative) summer nights are here than in the coverage area as a whole. Absent when the reference has not been generated.
comfort_pctDescriptive: share of all hours of the year in the comfort band.
mean_utci_c, min_utci_c, max_utci_cAnnual mean, minimum and maximum hourly UTCI, degrees C.
monthly_mean_utci_cMean UTCI per month; keys "1" to "12".
monthly_max_utci_c, monthly_min_utci_cHottest and coldest hourly UTCI per month, degrees C; keys "1" to "12".

Response fields, noise

Yearly noise from road traffic, rail and aircraft, read at the location by the same rule as the other pillars. Served in the Brussels-Capital Region; elsewhere the block is omitted and meta.metrics does not list it. It is a yearly figure, so hours does not change it. The score follows the total day-evening-night level of all sources together, on Brussels Environment's 2021 noise classes: very calm below 50 dB scores 7.5 or more, calm (50 to 60 dB) 6.5 to 7.5, noisy (60 to 70 dB) 5 to 6.5, very noisy (70 dB and above) below 5. A health block adds the share of residents who could be highly annoyed or highly sleep disturbed at this outdoor level, from the European dose-response curves (Directive (EU) 2020/367).

FieldMeaning
scoreThe noise score, 0 to 10, from the total day-evening-night level: 45 dB scores 9, 50 dB 7.5, 60 dB 6.5, 70 dB 5, 75 dB 3 and 85 dB 0, in straight lines between.
labelConsumer label for the score.
lday_db, levening_db, lnight_dbYearly level of all sources together, dB: day 07:00 to 19:00, evening 19:00 to 23:00, night 23:00 to 07:00.
lden_dbDay-evening-night level of all sources together, dB: the score's input.
main_sourceThe loudest source: road, rail or aircraft.
sourcesOne entry per source: key, name, lden_db and lnight_db (null where the source does not reach), who_lden_db and who_lnight_db (the World Health Organization guideline for that source), heard_pct (share of what you hear, an estimate from perceived loudness: 10 dB more sounds about twice as loud), energy_pct (share of the sound energy), ha_pct and hsd_pct (residents who could be highly annoyed or highly sleep disturbed by that source, %).
healthThe health indicator: score and label (0 to 10 from annoyance and sleep disturbance together, 7 where the World Health Organization guidelines sit), ha_pct and hsd_pct (all sources together), harm, night_share_pct, driver (the source doing the most harm, which can be quieter than the loudest one: aircraft disturbs more per decibel) and aircraft_penalty. Outdoor levels: indoors depends on windows, insulation and the side the bedrooms face.
sampling, distance_m, n_receiversHow the noise data was read: as for the other pillars, plus the number of points used.

Example

{
  "city": { "id": "brussels", "name": "Brussels", "country": "BE" },
  "location": { "address": null, "lat": 50.82068, "lon": 4.35821 },
  "meta": {
    "dataset": "brussels_extended",
    "reference_year": 2025,
    "sampling": "nearest_outdoor",
    "distance_m": 7.5,
    "n_cells": 3,
    "hours": 8760,
    "generated_utc": "2026-08-20T09:12:04Z"
  },
  "air": {
    "rating": 7.3,
    "score": 6.8,
    "label": "Good",
    "good_pct": 72.7,
    "dominant_pollutant": "NO2",
    "annual_mean_ugm3": { "no2": 21.4, "pm25": 7.9, "pm10": 14.7 },
    "...": "..."
  },
  "wind":    { "rating": 8.6, "calm_pct": 96.2, "...": "..." },
  "thermal": { "rating": 5.7, "tropical_nights": 8, "...": "..." }
}

Errors: validation_error (400), unauthorized (401), forbidden (403), outside_key_coverage (403), location_unavailable (403), outside_coverage (422), rate_limited (429), upstream_error (502), upstream_timeout (504).

GET/v1/historical/areas/{city}

Requires a key with the historical product, scoped to the city.

The same reference-year ratings as /v1/historical/stats, worked out for whole areas instead of one address. For Brussels the areas are the 19 communes, identified by their INS code, plus the whole region. Add the area id to the path to get just one: /v1/historical/areas/brussels/21009 for Ixelles, or /v1/historical/areas/brussels/region.

Each area gets a figure per pillar and per period: the full year, the four seasons (spring March to May, summer June to August, autumn September to November, winter December to February) and the twelve months (jan to dec). Pillars follow your key: a key holding only wind sees only wind.

The figures are computed in advance, so answers are instant. The hours parameter works as on /v1/historical/stats, so an address and its area can be compared over the same hours. The first request for a new window is computed on demand and can take up to a minute; after that it answers instantly. A city with no area statistics yet answers 404 not_found.

Two scopes per area

stats.all covers the whole area, every part weighted by the surface it covers. stats.highres carries the same figures and highres_share_pct is 100: area statistics are high resolution across the whole Brussels Capital Region, in the current high_res zone and in the part still in development alike.

Keys limited to the high-resolution zone

A key with coverage: ["high_res"] sees only the areas inside the current high-resolution zone, the part of the Region where GET /v1/geocode answers covered: high_res. Each area is taken as its part inside the zone, with figures computed on that part alone: the communes at least 80% inside it (today Etterbeek, Ixelles, Saint-Gilles and Saint-Josse-ten-Noode), the centre of the City of Brussels (21004-centre: the City of Brussels without Laeken), and the zone itself as region. share_of_parent_pct says how much of the commune that part covers; 21004-centre also carries kind: "part" and its parent commune. meta.coverage reads high_res. Any other area id answers 404 not_found.

Parameters

NameInRequiredMeaning
citypathyes City id from GET /v1/cities, e.g. brussels.
areapathno One area id (INS code for Brussels communes, 21004-centre for keys limited to the high-resolution zone) or region. Omit it to get every area.
hoursqueryno Local hours to keep, START-END, e.g. 8-20 or 22-6, as on /v1/historical/stats.

Response fields

FieldMeaning
cityThe city answered for: id, name, country.
meta.dataset, meta.reference_yearThe dataset and the year the statistics describe.
meta.area_kindWhat the areas are: commune for Brussels. For keys limited to the high-resolution zone, an area can also be part of a commune (21004-centre, marked kind: "part").
meta.boundariesWhich area boundaries were used: source, provider, retrieved.
meta.periodsThe periods and the months each one covers.
meta.hours_windowOnly with hours: the window applied.
meta.tail_m2The size of the spot behind worst, top and bottom: 1,000 m2.
meta.metricsThe pillars this response carries.
meta.coverageOnly for keys limited to the high-resolution zone: high_res.
meta.classesLabels of the classes used in dominant_class, per pillar.
meta.generated_utcWhen the statistics were computed.
areasOne entry per area (list form). The single form returns area instead.
regionThe whole city, same shape as an area (list form only). For keys limited to the high-resolution zone, the zone itself.
id, name, name_fr, name_nlArea id and names.
area_km2Surface of the area.
highres_km2, highres_share_pctThe surface rated at high resolution and its share of the area. Today the whole area, so the share is 100.
share_of_parent_pct, kind, parentOnly for keys limited to the high-resolution zone. share_of_parent_pct: how much of the commune the area covers. An area that is part of a commune (21004-centre) carries kind: "part" and its parent commune.
highres_outdoor_cells, lowres_cellshighres_outdoor_cells: how many high-resolution grid cells the figures rest on. lowres_cells is 0, since every area is rated at high resolution.
stats.{scope}.{pillar}.{period}The figures below, or null when there is no data.

The figures

FieldMeaning
averageArea-weighted mean of the ratings, 0 to 10. Each rating is the one /v1/historical/stats gives, restricted to the hours of the period.
worstMean rating of the area's worst 1,000 m2 of streets and squares. The same size everywhere, so a small and a large area are compared on equal terms. Shows how bad the worst spots get, which an average hides; null when it cannot be computed for the area.
dominant_classThe class that covers most of the area. Every grid cell contributes the class it spends the most hours in during the period.
class_share_pctShare of the area per class, keyed "1" upward: BelAQI 1 to 10 for air, wind classes 1 to 5, UTCI stress classes 1 to 10 for thermal.
rated_km2The surface the figures rest on.
valuesThe measured quantities behind the ratings, listed below. Each one comes as average (area-weighted), top (mean of the 1,000 m2 with the highest values) and bottom (mean of the 1,000 m2 with the lowest values). Which end is the worse one is in meta.values.
hours_class_pctAir only: share of hours in each BelAQI class, 1 to 10, averaged over the area.

Values

PillarValueUnitMeaning
airgood_hours_pct%Hours at BelAQI 1 to 4.
airbad_hours_pct%Hours at BelAQI 7 to 10.
airno2_ugm3, pm25_ugm3, pm10_ugm3, o3_ugm3µg/m3Mean concentration, estimated from the hourly BelAQI class.
windmean_speed_msm/sMean hourly wind speed.
windpeak_msm/sThe windiest moment of the period, gusts included.
windmax_gust_msm/sThe strongest gust of the period.
windgust_hourshoursHours with gusts of 10 m/s or more.
winddanger_hourshoursHours with wind or gusts above 15 m/s.
thermalmean_utci_c°CMean UTCI over the daytime hours, 08:00 to 20:00, or the window.
thermalheat_stress_hourshoursDaytime hours (or the window's) with UTCI above 32 °C.
thermalhot_daysdaysDays whose highest UTCI (over the window, if any) goes above 32 °C.
thermalcold_stress_hourshoursDaytime hours (or the window's) with UTCI below 0 °C.
thermalcold_daysdaysDays whose daytime UTCI (or the window's) drops below 0 °C.
noiselden_db, lnight_dbdBArea average of the day-evening-night level and of the night level, road traffic, rail and aircraft together.
noisehealth_score/10The health indicator of /v1/historical/stats, averaged over the area.
noiseha_pct, hsd_pct%Residents who could be highly annoyed or highly sleep disturbed at these outdoor levels.

Without hours, air and wind use every hour and thermal the daytime hours, 08:00 to 20:00 local; with it, all three use the window. The thermal year is the weakest of summer, spring and autumn, as in the point rating. Winter thermal figures are given too, but winter never counts toward the yearly rating.

Noise is a yearly level by day, evening and night, so hours does not change it. Its score is the noise score of /v1/historical/stats, its classes are Brussels Environment's 2021 classes: 1 very calm (below 50 dB), 2 calm (50 to 60 dB), 3 noisy (60 to 70 dB), 4 very noisy (70 dB and above). The seasons and months come from the model's daily levels and differ only a little.

Example

curl -H "Authorization: Bearer $CLARA_KEY" \
  "https://api.clara.city/v1/historical/areas/brussels/21013"
{
  "city": { "id": "brussels", "name": "Brussels", "country": "BE" },
  "meta": {
    "dataset": "brussels_extended",
    "reference_year": 2025,
    "area_kind": "commune",
    "metrics": ["air", "wind", "thermal"],
    "...": "..."
  },
  "area": {
    "id": "21013",
    "name": "Saint-Gilles - Sint-Gillis",
    "area_km2": 2.53,
    "highres_share_pct": 100.0,
    "stats": {
      "all": {
        "thermal": {
          "year": {
            "average": 5.8,
            "worst": 3.2,
            "values": {
              "heat_stress_hours": { "average": 133.7, "top": "...", "bottom": "..." },
              "hot_days": { "average": 30.4, "top": "...", "bottom": "..." },
              "...": "..."
            },
            "rated_km2": 2.53,
            "...": "..."
          },
          "...": "..."
        },
        "...": "..."
      },
      "highres": { "...": "..." }
    }
  }
}

Errors: unauthorized (401), forbidden (403), not_found (404), product_unavailable (422), rate_limited (429), upstream_error (502), upstream_timeout (504).

GET/v1/realtime

Requires a key with the realtime product.

Live hourly records for one location: pedestrian-level wind speeds, thermal comfort and pollutant concentrations, using the model data within radius metres of the point. Provide either address or both lat and lon. 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 /v1/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.

In Brussels the whole Brussels Capital Region is served: meta.dataset is brussels_extended inside the high-resolution zone (boundary at GET /v1/cities/brussels) and brussels_region in the rest of the Region. A key limited to the high-resolution zone receives 403 outside_key_coverage outside it.

Responses are cached server-side for 5 minutes per (lat, lon, radius) triple; X-Cache 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

NameInRequiredMeaning
addressqueryno Free-text address. Alternative to lat and lon.
latqueryno Latitude in decimal degrees (WGS 84).
lonqueryno Longitude in decimal degrees (WGS 84).
radiusqueryno Averaging radius in metres, 5 to 50. Default 10.

Response fields

FieldMeaning
city, locationThe shared envelope, as in /v1/historical/stats. Here city is always present.
meta.datasetWhich dataset answered: in Brussels, brussels_extended or brussels_region.
meta.radius_mAveraging radius applied, metres.
meta.hoursNumber of records in hourly.
meta.retrieved_utcWhen this response was assembled.
hourly[]Hourly records from the model, verbatim, one per hour. Records may carry additional fields as the model evolves.

Record fields

FieldMeaning
hourHour of the record, ISO 8601 UTC string.
timestampThe same instant as epoch milliseconds, as a string.
cNO2 concentration, µg/m3.
c_pm25, c_pm10PM2.5 and PM10 concentrations, µg/m3.
c_o3Ozone concentration, µg/m3.
ucomfortPedestrian-level mean comfort wind speed, m/s.
ugustPedestrian-level gust wind speed, m/s.
utciUniversal Thermal Climate Index, degrees C.

Example

{
  "city": { "id": "brussels", "name": "Brussels", "country": "BE" },
  "location": { "address": null, "lat": 50.82068, "lon": 4.35821 },
  "meta": {
    "dataset": "brussels_extended",
    "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, "c_pm25": 6.2, "c_pm10": 11.0, "c_o3": 48.1,
      "ucomfort": 1.2, "ugust": 2.1, "utci": 24.6
    },
    {
      "hour": "2026-08-20T10:00:00.000Z",
      "timestamp": "1787220000000",
      "c": 17.9, "c_pm25": 5.9, "c_pm10": 10.6, "c_o3": 51.3,
      "ucomfort": 1.3, "ugust": 2.4, "utci": 25.2
    }
  ]
}

Errors: validation_error (400), unauthorized (401), forbidden (403), outside_key_coverage (403), outside_coverage (422), product_unavailable (422), rate_limited (429), upstream_error (502), upstream_timeout (504).

GET/v1/forecast

Requires a key with the forecast product.

Forecast hourly records for one location, out to roughly 48 hours, in exactly the same record shape as GET /v1/realtime. The spacing between records may be irregular; read each record's timestamp rather than assuming a fixed interval.

Parameters, response fields and gating are identical to /v1/realtime: the location must fall inside a covered city that has the forecast product (GET /v1/cities?product=forecast).

Example

{
  "city": { "id": "brussels", "name": "Brussels", "country": "BE" },
  "location": { "address": null, "lat": 50.82068, "lon": 4.35821 },
  "meta": {
    "dataset": "brussels_extended",
    "radius_m": 10,
    "hours": 48,
    "retrieved_utc": "2026-08-20T10:15:42Z"
  },
  "hourly": [
    {
      "hour": "2026-08-20T11:00:00.000Z",
      "timestamp": "1787223600000",
      "c": 16.8, "c_pm25": 5.7, "c_pm10": 10.2, "c_o3": 55.4,
      "ucomfort": 1.5, "ugust": 2.9, "utci": 26.1
    },
    { "...": "..." }
  ]
}

Errors: validation_error (400), unauthorized (401), forbidden (403), outside_key_coverage (403), outside_coverage (422), product_unavailable (422), rate_limited (429), upstream_error (502), upstream_timeout (504).

POST/v1/reports

Requires a key with the reports product.

Starts asynchronous generation of an Environmental Intelligence Report, HTML plus PDF, for one location. Provide either address or both lat and lon in the JSON body; when only coordinates are given, the address label is resolved by reverse geocoding, falling back to "lat, lon" formatted to 5 decimals.

The address printed on the report is in the report's language. Brussels streets and municipalities have a French and a Dutch name: a Dutch report shows the Dutch address only, French and English reports the French one, whichever form the request used.

Every report is built in English, French and Dutch from the same analysis, each version with the address in its own language. lang picks the language of files in the job status; the other two come in translations. It is still one job and one unit of quota.

Any location in Belgium is accepted. Anywhere in the Brussels Capital Region the report is built from high-resolution data, and elsewhere in Belgium from the nationwide dataset. Locations outside every covered area receive an instant 422 outside_coverage without any work starting. Each accepted request 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.

Request body

FieldRequiredMeaning
addressno Free-text address inside a covered area, up to 200 characters. Alternative to lat and lon.
latno Latitude in decimal degrees (WGS 84).
lonno Longitude in decimal degrees (WGS 84).
radiusno Averaging radius in metres, 5 to 50. Default 10.
langno Main report language: en, fr or nl. Default en. The other two are built as well.
pillarsno The pillars the report shows, a list from noise, air, wind, thermal. Default: the metrics your key holds (all four for a key with every metric). Each one must be a metric your key holds, otherwise the request is refused with 403.
styleno The report layout. Only standard exists today.

Example request

{
  "address": "Chaussée de Waterloo 481, 1050 Ixelles",
  "lang": "en"
}

Response, 202 Accepted

FieldMeaning
job_idThe job's UUID, used in the status and download URLs.
statusAlways processing on acceptance.
stageAlways queued on acceptance.
links.statusRelative URL to poll for progress.
{
  "job_id": "3f8a2c1e-9b4d-4e6a-8c2f-1d5e7a9b0c3d",
  "status": "processing",
  "stage": "queued",
  "links": {
    "status": "/v1/reports/3f8a2c1e-9b4d-4e6a-8c2f-1d5e7a9b0c3d"
  }
}

Errors: validation_error (400), unauthorized (401), forbidden (403), outside_key_coverage (403), location_unavailable (403), outside_coverage (422), rate_limited (429), quota_exceeded (429), upstream_error (502), upstream_timeout (504).

GET/v1/reports/address

Requires a key with the reports product.

The address a report would print for the same address, lat, lon and lang, without creating the report and without using report quota. Use it to name the report before it exists, for example on a payment page. The answer is {"address": "Lakenweversstraat 2, 1050 Elsene", "lang": "nl"}: in the report's language, like the report itself. Where no report can be made (a city without reports, a city outside your key's scope, a point outside Belgium and every city, a location that is unavailable), it answers the same error POST /v1/reports would, so you can check before anyone pays.

GET/v1/reports/{job_id}

Requires a key with the reports product.

The current state of a report job. While it runs, 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. A permanently failed job returns status: failed with a detail.

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.

Parameters

NameInRequiredMeaning
job_idpathyes Report job id (UUID v4), from POST /v1/reports. Malformed ids return 400.

Response fields

FieldMeaning
job_idThe job's UUID.
statusprocessing, completed or failed.
stageWhile processing: queued, running or delivering.
addressOn completion: the address the report describes, in the report's language.
langOn completion: the report language.
files.html, files.pdfOn completion: relative, authenticated download URLs.
pdf_pendingPresent (true) only when the PDF is not ready yet; the next poll retries the rendering.
translationsOn completion: the same report in the other two languages, keyed by language code, each with its address and files.html, files.pdf download URLs. These links work at once: a PDF not rendered yet is rendered when it is asked for.
expires_in_daysRetention period of the generated files: 30.
detailOn failure: a short human-readable reason.

Example, completed

{
  "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" }
  },
  "translations": {
    "fr": {
      "address": "Chaussée de Waterloo 481, 1050 Ixelles",
      "files": {
        "html": { "url": "/v1/reports/3f8a2c1e-9b4d-4e6a-8c2f-1d5e7a9b0c3d/download?format=html&lang=fr" },
        "pdf":  { "url": "/v1/reports/3f8a2c1e-9b4d-4e6a-8c2f-1d5e7a9b0c3d/download?format=pdf&lang=fr" }
      }
    },
    "nl": {
      "address": "Waterlosesteenweg 481, 1050 Elsene",
      "files": {
        "html": { "url": "/v1/reports/3f8a2c1e-9b4d-4e6a-8c2f-1d5e7a9b0c3d/download?format=html&lang=nl" },
        "pdf":  { "url": "/v1/reports/3f8a2c1e-9b4d-4e6a-8c2f-1d5e7a9b0c3d/download?format=pdf&lang=nl" }
      }
    }
  },
  "expires_in_days": 30
}

Errors: validation_error (400), unauthorized (401), forbidden (403), not_found (404), rate_limited (429), upstream_error (502), upstream_timeout (504).

GET/v1/reports/{job_id}/download

Requires a key with the reports product.

Streams a generated report in the requested format. The response is sent as an attachment named CLARA_Report_{slug}.html or CLARA_Report_{slug}.pdf, where the slug is derived from the report address. Returns 404 when the file does not exist: unknown job, not yet generated, or expired after 30 days.

Parameters

NameInRequiredMeaning
job_idpathyes Report job id (UUID v4).
formatqueryyes html or pdf.
langqueryno One of the other languages listed in translations. Omitted, the main language. The URLs in the job status already carry it.

Example

curl -sS -OJ "https://api.clara.city/v1/reports/3f8a2c1e-9b4d-4e6a-8c2f-1d5e7a9b0c3d/download?format=pdf" \
  -H "Authorization: Bearer YOUR_CLARA_KEY"

The -OJ flags save the file under the name from the Content-Disposition header. The HTML variant is a self-contained page; the PDF is an A4 document.

Errors: validation_error (400), unauthorized (401), forbidden (403), not_found (404), rate_limited (429), upstream_unavailable (503, another language still being prepared: retry after the Retry-After seconds).

GET/v1/map/{city}

Requires a key with the map product.

The map catalog: every timestamp currently available for the city, each with a signed TileJSON URL per layer. This is the only map URL a client hardcodes; everything else is discovered from the response, already signed. Colouring is a separate question, answered by GET /v1/scales: what each layer can be plotted as, and the scales that colour it. The full integration walkthrough is the city map guide.

The catalog is assembled on demand, so it always reflects what is actually servable, and it is cached server-side for 60 seconds. If the upstream model service is unavailable, the last known-good catalog is served with X-Cache: stale and freshly re-signed URLs.

Parameters

NameInRequiredMeaning
citypathyes City identifier, lowercase. Cities with map coverage: GET /v1/cities?product=map.

Response fields

FieldMeaning
cityThe city that answered: id, name, country.
meta.crsCoordinate reference system of the tiles, EPSG:3857.
meta.tile_formatAlways mvt (Mapbox Vector Tiles).
meta.resolution_mGrid resolution of the dataset that answered, in metres.
meta.generated_utcWhen this catalog was assembled.
meta.catalog_ttl_sServer-side cache lifetime, seconds. Polling faster returns the same document.
meta.url_expires_utcWhen every signed URL in this catalog stops working. Re-fetch the catalog before then.
staticLayers with no time dimension, keyed by layer id, currently buildings. Each carries a signed tilejson URL.
realtimeOne timestep, the most recent observed hour: timestamp (ISO 8601 UTC), timestamp_ms (epoch milliseconds) and layers, a signed tilejson URL per layer. Replaced hourly, a few minutes past the hour.
forecast[]Upcoming timesteps in time order, same shape as realtime. Length and spacing vary; read each timestamp_ms. A layer key is absent when that layer has no data for a timestep.
rangePer layer and per hour, the min and max of that layer's values across the covered area at that timestep. Published for the thermal layer today. Absent means no range was published for that layer and hour. The relative scales, whose stops run 0 to 1, are mapped onto it.

The two time blocks follow the key's horizon scope. A key without realtime gets a catalog with no realtime block; a key without forecast gets an empty forecast array. Neither is an error, and neither means the city lacks the data. Check the scope with GET /v1/key.

Example

{
  "city": { "id": "ravenna", "name": "Ravenna", "country": "IT" },
  "meta": {
    "crs": "EPSG:3857",
    "tile_format": "mvt",
    "generated_utc": "2026-08-19T10:43:53Z",
    "catalog_ttl_s": 60,
    "url_expires_utc": "2026-08-20T11:00:00Z"
  },
  "static": {
    "buildings": { "tilejson": "https://api.clara.city/v1/map/ravenna/tiles/buildings.json?exp=...&sig=..." }
  },
  "realtime": {
    "timestamp": "2026-08-19T10:00:00Z",
    "timestamp_ms": 1787133600000,
    "layers": {
      "thermal": { "tilejson": "..." },
      "wind":    { "tilejson": "..." }
    }
  },
  "forecast": [
    { "timestamp": "2026-08-19T12:00:00Z", "timestamp_ms": 1787140800000, "layers": { "thermal": { "tilejson": "..." }, "wind": { "tilejson": "..." } } }
  ]
}

Errors: unauthorized (401), forbidden (403), not_found (404), rate_limited (429), upstream_unavailable (503).

Tile endpoints

TileJSON documents and vector tiles are fetched by your map library, not by your code, from the signed URLs the catalog hands out. They carry their authorisation in the query string (exp plus sig), stamped by the catalog: never construct or modify one. Listed here for completeness:

PathReturns
/v1/map/{city}/tiles/{layer}/{timestamp}.json TileJSON 3.0.0 for one realtime layer at one timestamp: tile URL template, vector layer names and fields, bounds, zoom range.
/v1/map/{city}/tiles/{layer}/{timestamp}/{z}/{x}/{y}.mvt One vector tile of a realtime layer.
/v1/map/{city}/tiles/forecast/{layer}/{timestamp}.json TileJSON for one forecast layer at one timestep.
/v1/map/{city}/tiles/forecast/{layer}/{timestamp}/{z}/{x}/{y}.mvt One vector tile of a forecast layer.
/v1/map/{city}/tiles/{static}.json TileJSON for a static layer, currently buildings.
/v1/map/{city}/tiles/{static}/{z}/{x}/{y}.mvt One vector tile of a static layer.

{layer} is thermal, wind or air; {timestamp} is the epoch milliseconds value from the catalog's timestamp_ms. An empty tile answers 204 No Content, which is not an error. A URL whose exp has passed answers 401 with code: url_expired; re-fetch the catalog for fresh URLs. A URL that is unsigned, or whose signature does not check out, answers 401 with code: unauthorized instead.

Source-layer names and feature properties per layer, plus working map code, are in the city map guide. The machine-readable contract is the Maps API OpenAPI specification.

Headers

HeaderMeaning
X-Request-Id Correlation id (UUID), on every response. Quote it when reporting a problem.
X-Cache hit, miss or stale: whether the body came from the server-side cache. stale appears only on the map catalog, when the model service was unreachable and a cached catalog was served.
X-RateLimit-Limit On keyed responses: requests allowed per minute.
X-RateLimit-Remaining On keyed responses: requests left in the current minute.
X-RateLimit-Reset On keyed responses: start of the next minute window, epoch seconds.
Retry-After On a 429: seconds to wait before retrying.
Content-Disposition On report downloads: the attachment filename.

Error codes

Every error is an RFC 9457 problem document with a machine-readable code to branch on, and its type URI links to the matching entry of the error reference:

{
  "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"
}

One rule: a 403 is always about your key, a 404 or 422 always about the data.

CodeStatusMeaning
validation_error400A parameter is missing or malformed.
unauthorized401The key is missing or unknown.
url_expired401A signed tile URL outlived its expiry.
forbidden403The key is valid but not entitled to this.
outside_key_coverage403The location is outside the zone the key is limited to.
location_unavailable403No data or report is available for this location.
not_found404No such resource.
outside_coverage422The location is outside every covered area.
product_unavailable422The city exists but does not have this product.
rate_limited429Too many requests this minute; retry after Retry-After seconds.
quota_exceeded429The daily report quota is used up; it resets at midnight UTC.
upstream_error502Something behind the API failed; retry.
upstream_unavailable503The model service is unreachable and no cached catalog exists.
upstream_timeout504The computation did not finish in time; retry.

Each code's full entry, with what to do about it, is in the error reference.