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
| Field | Meaning |
|---|---|
service | Always clara-api. |
status | Always ok when you get a 200. |
version | The API version currently serving. |
time | Current 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
| Name | In | Required | Meaning |
|---|---|---|---|
product | query | no | Only return cities that have this product. One of
geocode, historical,
realtime, forecast,
reports, map. |
Response fields
| Field | Meaning |
|---|---|
generated_utc | When the index was assembled. |
cities[].id | City identifier, lowercase, as used in URLs. |
cities[].name | Display name. |
cities[].country | ISO 3166-1 alpha-2 country code. |
cities[].timezone | IANA timezone name. |
cities[].bbox | Bounding box, [west, south, east, north] in decimal degrees. |
cities[].products | One boolean per product: geocode, historical, realtime, forecast, reports, map. |
cities[].map | Map product details, present when products.map is true: layers (the available layer ids), forecast_hours (approximate horizon) and cadence (hourly). |
cities[].authorized | Only 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
| Name | In | Required | Meaning |
|---|---|---|---|
city | path | yes | City identifier, lowercase, from GET /v1/cities. |
Response fields
| Field | Meaning |
|---|---|
All the fields of a /v1/cities entry, plus: | |
reference_year | Reference year of the historical dataset. |
attribution | Attribution string to display alongside the data. |
boundary | GeoJSON 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
| Name | In | Required | Meaning |
|---|---|---|---|
metric | query | no | Limit the answer to air, wind or thermal. |
Response fields
| Field | Meaning |
|---|---|
views[].id | What to plot, e.g. gust. |
views[].fields | Tile properties this view reads. |
views[].scales | Scale ids valid for it, in display order. |
views[].default_scale | Which to use when the client has no preference. |
scales[].id | Scale identifier for GET /v1/scales/{scale}. |
scales[].view | The view this scale colours. |
scales[].kind | classes draws a swatch list, gradient a colour bar. |
scales[].authority | Who 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
| Name | In | Required | Meaning |
|---|---|---|---|
scale | path | yes | 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
| Field | Meaning |
|---|---|
id, metric, view, name,
authority, kind, default |
As in the scale index. |
description | What the scale measures and how its value is derived. |
fields | The tile properties this scale reads, e.g. Ucomfort and Ugust. |
unit | Unit 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_color | Colour 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
| Field | Meaning |
|---|---|
label | Who or what the key was issued for. |
type | sk (secret, server side) or pk (publishable). |
cities | City scope. ["*"] means every city. |
products | Product scope. ["*"] means every product. |
limits | rate_per_min (requests per minute) and reports_per_day (daily report quota). |
origins | Origin allowlist, enforced for publishable keys when a browser sends an Origin header. |
created | Issue date. |
expires_at | Expiry 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
| Name | In | Required | Meaning |
|---|---|---|---|
q | query | yes | Free-text address query. |
limit | query | no | Maximum number of results, 1 to 5. Default 5. |
Response fields
| Field | Meaning |
|---|---|
query | The query string as received. |
results[].address | Street and number, postcode and municipality, in a single language. |
results[].lat, results[].lon | Coordinates in decimal degrees (WGS 84). |
results[].relevance | Match confidence reported by the geocoder, 0 to 1. |
results[].covered | Whether the result lies inside a CLARA high-resolution zone: a strict point-in-polygon check against the boundary at GET /v1/cities/{city}. |
results[].city | Identifier 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.
X-Cache header says which happened. Set your client
timeout generously on the first call.
Parameters
| Name | In | Required | Meaning |
|---|---|---|---|
address | query | no | Free-text address; the top geocoding result is used.
Alternative to lat and lon. |
lat | query | no | Latitude in decimal degrees (WGS 84). |
lon | query | no | Longitude in decimal degrees (WGS 84). |
radius | query | no | 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. |
metrics | query | no | Comma-separated subset of air,wind,thermal, any
order. Default: all blocks. |
Response fields, envelope
| Field | Meaning |
|---|---|
city | Which city answered: id, name, country. Absent for nationwide answers. |
location | The resolved address label (null for raw coordinates) plus lat and lon as used, rounded to 5 decimals. |
meta.dataset | Which dataset answered: brussels_extended (street-level central Brussels) or belgium_lowres (nationwide). Same quantities and scoring methods in both. |
meta.reference_year | The year the statistics describe. |
meta.sampling | point (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_m | Only for nearest_outdoor: how far the value came from, metres. |
meta.radius_m | Only when a radius was supplied: the radius applied, metres. |
meta.n_cells | Number of outdoor grid cells averaged. |
meta.hours | Hourly records in the reference year. |
meta.generated_utc | When 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.
| Field | Meaning |
|---|---|
rating | The headline air number, 0 to 10: the share of graded hours at BelAQI class 4 ("fairly good") or better, divided by 10. |
score | CLARA 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. |
label | Consumer label for the score: Excellent, Very good, Good, Moderate, Poor or Bad. |
hours_graded | Hours with valid air quality data. |
annual_mean_ugm3 | Estimated yearly average concentration per pollutant (no2, pm25, pm10), µg/m3; null when a pollutant has no data. |
pollutant_scores | Per-pollutant WHO subscore, 0 to 10. The score is the lowest of the three. |
limiting_pollutants | Every pollutant sitting on the lowest subscore, ordered NO2, PM2.5, PM10. |
limiting_pollutant | First entry of limiting_pollutants, kept as a convenience. |
distribution_pct | Share of graded hours per BelAQI class, keyed by class label. |
grouped_pct | The same distribution in four bands: g12, g34, g56, g710. |
good_pct | Share of graded hours at BelAQI 1 to 4, percent. |
day_good_pct, night_good_pct | Good-air share for local hours 08:00 to 20:00 and 20:00 to 08:00. |
monthly_good_pct | Good-air share per month; keys are month numbers "1" to "12". |
best_month, worst_month | Months with the highest and lowest good-air share. |
dominant_pollutant | Pollutant most often responsible for the hourly index value. |
pollutant_share_pct | Share of graded hours each pollutant dominated. |
benchmark | Optional 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.
| Field | Meaning |
|---|---|
rating | The headline wind number, 0 to 10: the minimum of score and a worst-moment ladder read from the year's windiest hour. |
score | CLARA 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. |
label | Consumer label for the score. |
distribution_pct | Share of hours per wind comfort class, keyed by class label. |
calm_pct | Share of hours in class 1, Calm. |
eff_mean_ms | Annual mean of the hourly effective speed, m/s. |
worst_moment_ms | The year's windiest hour as an effective speed, m/s. |
worst_moment_hours | Hours of the year within 0.5 m/s of the worst moment (at least 1). |
exceedance_pct | Cumulative 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_ms | Annual mean pedestrian-level wind speed, m/s. |
p95_ucomfort_ms | 95th percentile of the pedestrian-level wind speed, m/s. |
max_gust_ms | Maximum gust speed over the year, m/s. |
monthly_mean_ucomfort_ms | Mean 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.
| Field | Meaning |
|---|---|
rating | The 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. |
score | CLARA 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. |
label | Consumer label for the score. |
seasonal_comfort_pct | Comfortable share (9 to 26 degrees C) of each season's daytime hours, all four seasons, the rating's input. |
rating_limiting_seasons, rating_limiting_season | Outdoor seasons tied on the weakest comfortable share; the first as a convenience. |
seasonal_acceptable_pct | Acceptable 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_season | Seasons sitting on the lowest subscore; the first as a convenience. |
summer_day_comfort_pct | Descriptive: share of summer daytime hours in the comfort band. |
strong_heat_stress_days | Descriptive: days whose maximum UTCI exceeds 32 degrees C. |
tropical_nights | Descriptive: nights (20:00 to 08:00 local) whose minimum UTCI stays above 20 degrees C. |
summer_night_mean_utci_c | Mean UTCI over summer night hours at this location. |
summer_night_vs_city_c | How 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_pct | Descriptive: share of all hours of the year in the comfort band. |
mean_utci_c, min_utci_c, max_utci_c | Annual mean, minimum and maximum hourly UTCI, degrees C. |
monthly_mean_utci_c | Mean 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
| Name | In | Required | Meaning |
|---|---|---|---|
address | query | no | Free-text address. Alternative to lat and lon. |
lat | query | no | Latitude in decimal degrees (WGS 84). |
lon | query | no | Longitude in decimal degrees (WGS 84). |
radius | query | no | Averaging radius in metres, 5 to 50. Default 10. |
Response fields
| Field | Meaning |
|---|---|
city, location | The shared envelope, as in /v1/historical/stats. |
meta.dataset | Which dataset answered. |
meta.radius_m | Averaging radius applied, metres. |
meta.hours | Number of records in hourly. |
meta.retrieved_utc | When 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
| Field | Meaning |
|---|---|
hour | Hour of the record, ISO 8601 UTC string. |
timestamp | The same instant as epoch milliseconds, as a string. |
c | NO2 concentration, µg/m3. |
c_pm25, c_pm10 | PM2.5 and PM10 concentrations, µg/m3. |
c_o3 | Ozone concentration, µg/m3. |
ucomfort | Pedestrian-level mean comfort wind speed, m/s. |
ugust | Pedestrian-level gust wind speed, m/s. |
utci | Universal 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
| Field | Required | Meaning |
|---|---|---|
address | no | Free-text address inside a covered area, up to 200
characters. Alternative to lat and
lon. |
lat | no | Latitude in decimal degrees (WGS 84). |
lon | no | Longitude in decimal degrees (WGS 84). |
radius | no | Averaging radius in metres, 5 to 50. Default 10. |
lang | no | Report language: en, fr or
nl. Default en. |
Example request
{
"address": "Chaussée de Waterloo 481, 1050 Ixelles",
"lang": "en"
}
Response, 202 Accepted
| Field | Meaning |
|---|---|
job_id | The job's UUID, used in the status and download URLs. |
status | Always processing on acceptance. |
stage | Always queued on acceptance. |
links.status | Relative 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
| Name | In | Required | Meaning |
|---|---|---|---|
job_id | path | yes | Report job id (UUID v4), from POST /v1/reports.
Malformed ids return 400. |
Response fields
| Field | Meaning |
|---|---|
job_id | The job's UUID. |
status | processing, completed or failed. |
stage | While processing: queued, running or delivering. |
address | On completion: the address the report describes. |
lang | On completion: the report language. |
files.html, files.pdf | On completion: relative, authenticated download URLs. |
pdf_pending | Present (true) only when the PDF is not ready yet; the next poll retries the rendering. |
expires_in_days | Retention period of the generated files: 30. |
detail | On 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
| Name | In | Required | Meaning |
|---|---|---|---|
job_id | path | yes | Report job id (UUID v4). |
format | query | yes | 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
| Name | In | Required | Meaning |
|---|---|---|---|
city | path | yes | City identifier, lowercase. Cities with map coverage:
GET /v1/cities?product=map. |
Response fields
| Field | Meaning |
|---|---|
city | The city that answered: id, name, country. |
meta.crs | Coordinate reference system of the tiles, EPSG:3857. |
meta.tile_format | Always mvt (Mapbox Vector Tiles). |
meta.generated_utc | When this catalog was assembled. |
meta.catalog_ttl_s | Server-side cache lifetime, seconds. Polling faster returns the same document. |
meta.url_expires_utc | When every signed URL in this catalog stops working. Re-fetch the catalog before then. |
static | Layers with no time dimension, keyed by layer id, currently buildings. Each carries a signed tilejson URL. |
live | One 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:
| Path | Returns |
|---|---|
/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
| Header | Meaning |
|---|---|
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.
| Code | Status | Meaning |
|---|---|---|
validation_error | 400 | A parameter is missing or malformed. |
unauthorized | 401 | The key is missing or unknown. |
url_expired | 401 | A signed tile URL outlived its expiry. |
forbidden | 403 | The key is valid but not entitled to this. |
not_found | 404 | No such resource. |
outside_coverage | 422 | The location is outside every covered area. |
product_unavailable | 422 | The city exists but does not have this product. |
rate_limited | 429 | Too many requests this minute; retry after Retry-After seconds. |
quota_exceeded | 429 | The daily report quota is used up; it resets at midnight UTC. |
upstream_error | 502 | Something behind the API failed; retry. |
upstream_unavailable | 503 | The model service is unreachable and no cached catalog exists. |
upstream_timeout | 504 | The computation did not finish in time; retry. |
Each code's full entry, with what to do about it, is in the error reference.