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 street-level air quality, pedestrian wind comfort and thermal comfort, 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, present when the location falls inside a covered city), 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. The long form, including how to get a key and how origin registration works, is in 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 connectivity check before anything else.

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 additionally carries authorized, whether that key is scoped to the city. The response is cacheable for an hour (Cache-Control: public, max-age=3600).

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[].mapMap product details, present when products.map is true: layers (the available layer ids), forecast_hours (approximate horizon) 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. Cacheable for an hour; it changes only when coverage changes.

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

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": {},
    "geometry": {
      "type": "Polygon",
      "coordinates": [[[4.31282, 50.87911], [4.31355, 50.87801], "..."]]
    }
  }
}

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. A scale is how to colour one view. These are the definitions behind every coloured value CLARA renders, published once so clients never hardcode them. Cacheable for a day; a scale changes only when its issuing authority revises it.

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.

Example

GET /v1/scales?metric=wind

{
  "views": [
    { "id": "comfort-safety", "metric": "wind", "name": "Wind comfort and danger",
      "fields": ["Ucomfort", "Ugust"],
      "field_labels": { "Ucomfort": "Wind", "Ugust": "Gust" },
      "scales": ["wind-nen8100", "wind-cvd"],
      "default_scale": "wind-nen8100", "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-nen8100", "view": "comfort-safety", "kind": "classes",
      "name": "Standard", "default": true },
    { "id": "wind-cvd", "view": "comfort-safety", "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-nen8100, wind-cvd, wind-mean-rainbow, wind-gust-rainbow, air-belaqi, air-no2, air-pm25, air-pm10, thermal-utci. 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.

Example

{
  "id": "belaqi",
  "name": "BelAQI air quality index",
  "authority": "IRCEL-CELINE (Belgium)",
  "description": "Belgian air quality index, 1 (Excellent) to 10 (Horrible). The hourly value is the worst per-pollutant class.",
  "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":  [10, 15, 20, 30, 40, 45, 50, 60, 75],
    "pm25": [3.5, 7.5, 10, 15, 20, 35, 50, 60, 75],
    "pm10": [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, 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, so you never have to guess what you were given.

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.
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"],
  "limits": { "rate_per_min": 100, "reports_per_day": 100 },
  "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. results may be empty when nothing matches.

Parameters

NameInRequiredMeaning
qqueryyes Free-text address query.
limitqueryno Maximum number of results, 1 to 5. Default 5.

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).
results[].relevanceMatch confidence reported by the geocoder, 0 to 1.
results[].coveredWhether the result lies inside a CLARA high-resolution zone: a strict point-in-polygon check against the boundary at GET /v1/cities/{city}.
results[].cityIdentifier of the covering city. Present when covered is true.

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

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 and thermal comfort, 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 5 decimals before processing.

Any location in Belgium is served. Inside the Brussels high-resolution zone (boundary at GET /v1/cities/brussels) the answer comes from the street-level model; elsewhere in Belgium it comes from a lower-resolution 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, 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. 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, any order. Default: all blocks.

Response fields, envelope

FieldMeaning
cityWhich city answered: id, name, country. Absent for nationwide answers.
locationThe resolved address label (null for raw coordinates) plus lat and lon as used, rounded to 5 decimals.
meta.datasetWhich dataset answered: brussels_extended (street-level central Brussels) or belgium_lowres (nationwide). Same quantities and scoring methods in both.
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 cells were 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 in the reference year.
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".
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.
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.
tropical_nightsDescriptive: nights (20:00 to 08:00 local) whose minimum UTCI stays above 20 degrees C.
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".

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_coverage (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.

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.
meta.datasetWhich dataset answered.
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_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_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.

Any location in Belgium is accepted: inside the Brussels high-resolution zone the report is built from the street-level model, elsewhere 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 Report language: en, fr or nl. Default en.

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_coverage (422), rate_limited (429), quota_exceeded (429), upstream_error (502), upstream_timeout (504).

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

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

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. How to colour those layers is a separate question, answered by GET /v1/scales, which lists 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.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.
liveOne 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 live. Length and spacing vary; read each timestamp_ms. A layer key is absent when that layer has no data for a timestep.

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=..." }
  },
  "live": {
    "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 live 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 live 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. An expired or invalid signature answers 401 with code: url_expired; re-fetch the catalog for fresh URLs.

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 keeps them easy to reason about: a 403 is always about your key, a 404 or 422 is 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.
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.