# Austrian Cadastre API v1 — LLM Reference >> NEED ALL VERTICES FOR MANY PARCELS OR FOOTPRINTS? Don't loop one HTTP call >> per ID. Use the batch/viewport geometry endpoints instead of the N+1 >> chaining pattern (search/parcel?kg=X → parcels/{id}/footprints → footprints/{id}/geometry): >> GET/POST /api/v1/parcels/geometry/batch {"ids":["63349-348/6", ...]} >> GET/POST /api/v1/footprints/geometry/batch {"ids":["63349_fp_9", ...]} >> GET /api/v1/spatial/parcels?west=&south=&east=&north= (map viewport) >> GET /api/v1/spatial/footprints?west=&south=&east=&north= (map viewport) >> Up to 5000 IDs/rows per call (max 20000 for viewport via &limit=), full >> polygon coordinates included by default (?geometry=0 to omit). Parcel IDs >> containing a slash (e.g. 413/31) must be percent-encoded once as %2F, not >> double-encoded. For ALL footprints/parcels in a whole KG in one shot, skip >> IDs entirely and use GET /api/v1/export/geojson?kg=X&layers=... >> PUBLISHING AUSTRIAN CIVIC DATA? Read the "Integration Spec for Sibling Data >> Services" section below (or fetch it alone at /api/v1/docs/llm.txt?section=integration). >> Every sibling service must expose GET /llm/kg/{kg_code}. Skip the rest. >> READ THIS IF A RESULT LOOKS EMPTY OR INCOMPLETE. >> Parcel and footprint geometry is served from lazily-warmed per-KG caches. A KG >> that hasn't been touched yet must be fetched from Zenodo (~2s) first. Endpoints >> never block on that — they return what is cached, queue the rest, and TELL YOU: >> "ready": false (viewport/batch geometry endpoints) — some KGs in >> this bbox are still warming; result may be partial >> "pending": true (per result in POST /spatial/points) — this point's >> KG isn't loaded, so matched:false means UNKNOWN >> "complete": false (meta) — at least one item is pending >> "coords_verified": false (building rows) — this KG's address points have not >> been re-joined to parcels yet >> CORRECT RESPONSE: repeat the same request after a few seconds. Warming is >> triggered by your first call, so retries converge. Do NOT interpret >> ready:false / pending:true as "no data exists here", and do not hammer in a >> tight loop — one retry every few seconds per KG is enough. >> Progress: /api/v1/spatial/geom_cache/stats, /api/v1/footprints/geom_cache/stats, >> /api/v1/buildings/repair/stats, /api/v1/prewarm/stats >> If you are an operator and the cold-KG penalty matters for YOUR access >> pattern: the API records which KGs get requested, so >> './cadastre-server -prewarm-kgs' warms exactly those. See /api/v1/prewarm/stats. >> On this deployment that pass also runs WEEKLY (systemd cadastre-prewarm.timer), >> so a KG you requested once is normally warm by the next week. >> Building address points (coords_verified) are 100 % repaired nationwide as of >> 2026-08-05, so in practice only the two geometry caches can still report >> ready:false — and only for a KG nobody has touched yet. >> PERFORMANCE NOTES FOR AGENTS >> - Prefer the batch/viewport endpoints above over per-ID loops (N+1). >> - COMPRESSION: responses are gzipped when you send 'Accept-Encoding: gzip' >> (a Graz viewport is 2.7 MB raw vs 508 KB gzipped, -94 %). Python requests/ >> httpx, Go net/http, browsers and axios do this and decompress transparently >> — nothing to change. Plain curl does NOT: add --compressed. If you set >> the header MANUALLY in Go you must gunzip yourself; let the library do it. >> Compressed replies are chunked, so there is no Content-Length. >> - JSON is COMPACT by default; append ?pretty=1 for indented output. Parsers are >> unaffected; only line-oriented grep/awk pipelines care. >> - Coordinates are emitted at 7 decimals (~0.8 cm, finer than the BEV source), >> so don't do exact float equality against older cached output. >> - CACHING: idempotent /api/ GETs return an ETag and Cache-Control max-age=120. >> Send If-None-Match to get a 0-byte 304 instead of the payload. Live endpoints >> (/debug, /inspire/process, /feedback, /buildings/repair, /prewarm) are exempt. >> Add ?nocache= or Cache-Control: no-cache to force fresh. >> - /spatial/parcels and /spatial/footprints read straight from the R-tree and >> are the fastest way to get geometry for a map viewport (single-digit ms). >> - /spatial/bbox&layers=buildings returns ADDRESS POINTS (no geometry); use >> /spatial/footprints for building polygons. >> - Add ?geometry=0 when you only need attributes/metrics — much smaller payload. >> - For everything in one KG, use /api/v1/export/geojson?kg=X rather than a bbox. >> - Concurrency: the API handles parallel requests fine, but there is no benefit >> beyond ~8-16 in flight; batch endpoints beat client-side fan-out. >> - Debugging a slow call: /api/v1/debug/inflight shows in-flight requests and >> their ages plus DB pool stats. >> - Wide admin filters (?state=, ?district=) are fine now, but their aggregate >> stats may be a few minutes stale on state-sized sets (see /api/v1/query). >> Pass refresh=1 if you need them recomputed synchronously. Base URL: /api/v1 Formats: All endpoints accept ?format=json (default) | geojson | csv | gpkg Pagination: ?limit=N&offset=N (default limit=100, max=100000) Sparse fields: ?fields=parcel_id,gnr,area_sqm (return only specified fields) JSONP: ?callback=fn CORS: All origins allowed. ## Endpoint Reference ### SPATIAL — Query features by geography GET /api/v1/spatial/bbox Query features within a bounding box. Required: west, south, east, north (floats, WGS84 degrees) Aliases: minlon=west, minlat=south, maxlon=east, maxlat=north Optional: buffer (meters, expand bbox), layers (parcels|buildings|kg, comma-separated, default parcels), landuse (code or abbr), min_area, max_area (sqm), has_buildings (true|false), attrs_only (true = centroids only, no geometry), limit, offset, format Buildings include _layer, parcel_id, and ez (joined from parcel_buildings table). Example: /api/v1/spatial/bbox?west=15.0&south=47.0&east=15.1&north=47.1&layers=parcels Example: /api/v1/spatial/bbox?minlon=15.0&minlat=47.0&maxlon=15.1&maxlat=47.1&layers=buildings GET /api/v1/spatial/point Query features near a point. Required: lon, lat (WGS84) Optional: radius (meters, default 100), layers (or layer — both accepted, and singular layer NAMES like "building"/"parcel" are accepted too), landuse, min_area, max_area, has_buildings, attrs_only, limit, offset, format Response includes distance_m for each result. Buildings include parcel_id and ez (geometry-derived: the point lies inside that parcel) plus coords_verified — false means this KG's address points have not been re-joined yet; see /api/v1/buildings/repair/stats. Example: /api/v1/spatial/point?lon=15.05&lat=47.05&radius=500&layers=buildings POST /api/v1/spatial/point Batch spatial point query — up to 1000 points in one request. Content-Type: application/json Body: {"points": [{"lon": 15.1, "lat": 47.2, "id": "farm1"}, ...], "radius": 50, "layers": ["parcels"]} Fields: points (required): array of {lon, lat, id?} — max 1000 points radius (optional): meters, default 100, max 50000 layers (optional): array of layer names, default ["parcels"] Response: {"results": [{"id": "farm1", "lon": 15.1, "lat": 47.2, "parcels": [...], "buildings": [...]}, ...], "meta": {"point_count": N, "total_features": N, "radius_m": 50, "layers": [...]}} Each result contains only the layers requested. Empty layers are empty arrays. Results include distance_m per feature. Example body: {"points": [{"lon": 15.05, "lat": 47.05, "id": "A"}, {"lon": 15.06, "lat": 47.06, "id": "B"}], "radius": 200, "layers": ["parcels", "buildings"]} POST /api/v1/spatial/points Batch point-in-polygon containment — up to 1000 points in one request. True containment: loads actual parcel polygon geometry, performs exact point-in-polygon. Unlike POST /spatial/point (radius-based proximity on centroids), this tests geometric containment. Use it to resolve arbitrary coordinates (addresses, GPS fixes, site markers) to the parcel they actually fall inside. Content-Type: application/json Body: {"points": [{"lon": 15.0856, "lat": 47.0662, "id": "farm1"}, ...], "wait": false} Fields: points (required): array of {lon, lat, id?} — max 1000 points wait (optional, default false): block until every KG is loaded (see below) Response: {"results": [{"id": "farm1", "matched": true, "kg_code": "63332", "kg_name": "...", "parcel": {"parcel_id": "63332-347/3", "gnr": "347/3", "area_sqm": 582.5, "landuse_summary": {...}, ...}}, ...], "meta": {"point_count": N, "matched": N, "unmatched": N, "pending": N, "kgs_warming": N, "complete": true|false, "cache_resolved": N, "kgs_loaded": N, "duration_ms": N, "method": "point-in-polygon", "geom_cache_ready": true}} ANSWER-NOW SEMANTICS (important — read before interpreting matched:false) A KG that isn't in the geometry cache yet must be fetched from Zenodo (~2s) before it can answer. This endpoint does NOT wait for that: it returns what the cache already knows and queues the missing KGs for background warming. - "pending": true on a result => matched:false means UNKNOWN, *not* "there is no parcel at this coordinate". Do not treat it as a negative. - "complete": false in meta => at least one point is pending. - "kgs_warming" => how many KGs were queued by this call. Simply repeat the identical request after a few seconds; pending shrinks to 0 as KGs warm and the answer becomes authoritative. Send {"wait": true} to block until everything is loaded instead (slow for wide/national batches: it may download hundreds of KGs serially — a 500-point national batch measured 173s with wait, 0.25s without). Backed by a persistent parcel-geometry R-tree (WKB + SQLite R*Tree) in the search index. After a KG has been queried once, containment is served from the R-tree: no JSON load, no Zenodo download, instant across restarts (~1ms; cache_resolved counts cache-served points). Cache is LRU-capped (12 GB, ~3.9 GB projected for the whole country, so in steady state nothing is evicted); whole KGs evicted least-recently-used. Effective budgets: /api/v1/prewarm/stats -> disk. Containment is tested against ALL parts of multi-part (MultiPolygon) parcels, so points on a detached alpine piece resolve correctly. See GET /api/v1/spatial/geom_cache/stats. Example body: {"points": [{"lon": 15.0856, "lat": 47.0662, "id": "hof-A"}, {"lon": 15.09, "lat": 47.068, "id": "hof-B"}]} GET /api/v1/spatial/polygon (also POST with GeoJSON body) Query features within a polygon. Provide polygon as: POST body with GeoJSON Feature/Polygon, or ?geojson={...} Optional: buffer (meters), layer, landuse, min_area, max_area, has_buildings, attrs_only, limit, offset, format POST /api/v1/spatial/polygon (batch — FeatureCollection) Batch polygon intersection — up to 500 polygons in one request. Content-Type: application/json Body: GeoJSON FeatureCollection with Polygon features, or {"polygons": [{geometry}, ...]} Optional body fields: layers (array, default ["parcels"]) Feature IDs: extracted from properties.id, properties.ID, properties.fid, or auto-generated. Response: {"results": [{"id": "field_001", "parcels": [...], "buildings": [...]}, ...], "meta": {"polygon_count": N, "total_features": N, "layers": [...]}} Single polygon POSTs (Feature or Polygon type) remain backward compatible. Example body: {"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {"id": "f1"}, "geometry": {"type": "Polygon", "coordinates": [[[15.07,47.05],[15.08,47.05],[15.08,47.06],[15.07,47.06],[15.07,47.05]]]}}, ...]} GET /api/v1/spatial/kgs Find KG codes intersecting a bounding box. Lightweight — returns only KG identifiers and admin info. Required: west, south, east, north (floats, WGS84 degrees) Aliases: minlon=west, minlat=south, maxlon=east, maxlat=north Optional: buffer (meters), geojson (GeoJSON Polygon/Feature for precise polygon filtering), fields (sparse fieldset, e.g. kg_code,kg_name), limit, offset, format Response: {"data": {"kg_codes": ["63301",...], "kgs": [{kg_code, kg_name, gemeinde_code, gemeinde_name, district_code, district_name, state_name, parcel_count, building_count, total_area_sqm, bbox},...], "bbox": {...}}, "meta": {...}} Example: /api/v1/spatial/kgs?west=15.0&south=47.0&east=15.1&north=47.1 Example: /api/v1/spatial/kgs?west=15.0&south=47.0&east=15.5&north=47.5&fields=kg_code,kg_name POST /api/v1/spatial/kgs Find KG codes overlapping a GeoJSON polygon. Uses proper geometry overlap: polygon vertex inside KG bbox, KG bbox corner inside polygon, and edge-crossing detection. Content-Type: application/json Body: GeoJSON Polygon or Feature with Polygon geometry. Optional: limit, offset, format, fields (query params) Example body: {"type": "Polygon", "coordinates": [[[15.0,47.0],[15.1,47.0],[15.1,47.1],[15.0,47.1],[15.0,47.0]]]} ### SEARCH — Find features by attributes GET /api/v1/search/text Universal full-text search across parcels, buildings, and admin names. Required: q (search text) Optional: limit, offset, format Searches: parcel IDs, GNR, EZ, addresses, streets, KG/Gemeinde/district names Returns: mixed array of {type: "parcel"|"building"|"kg", ...properties} Falls back to OR matching if AND yields no results. Example: /api/v1/search/text?q=Kohlschwarz+9 GET /api/v1/search/address Search building addresses. Optional: q (full-text), plz (postal code), ort (locality), street, limit, offset, format At least one parameter required. Example: /api/v1/search/address?q=Hauptstraße&plz=8580 GET /api/v1/search/gnr Grundstücksnummer (parcel number) lookup. Required: gnr (exact match, or partial with * wildcards or . prefix) Optional: kg (KG code to narrow), limit, offset, format Example: /api/v1/search/gnr?gnr=1314/1&kg=75414 Example: /api/v1/search/gnr?gnr=.1&kg=85101 (partial match) GET /api/v1/search/ez Einlagezahl (land register folio) lookup. Required: kg or gemeinde (KG code, or Gemeinde code/name — searches across all KGs in the Gemeinde) Optional: ez (specific EZ; omit to list all EZs), limit, offset, format If ez given: returns EZ summary + all parcels in that EZ + buildings array (addresses on the EZ). Also includes bbox (centroid and bounding box of the EZ's parcels) and landuse_area_breakdown. If ez omitted: returns all EZ summaries. Each includes parcel_count, total_area_sqm, building_count, bbox. gemeinde parameter: pass a Gemeinde code (e.g. 61630) or name to search EZs across all KGs in that Gemeinde. Building-coordinate verification (see GET /api/v1/buildings/repair/stats): meta.buildings_coords_verified — false means at least one KG's address points are still being repaired, NOT that there are no buildings. meta.buildings_repair_pending_kgs — the KGs concerned; retry shortly, or force with POST /api/v1/buildings/repair?kg=. The ez-list form (no ?ez=) never blocks (~4 ms). The single-EZ form returns building rows, so it waits up to 3 s for their repair; ?wait= overrides that (0 = don't wait, max 60). Example: /api/v1/search/ez?kg=75414&ez=67 Example: /api/v1/search/ez?gemeinde=61630 Example: /api/v1/search/ez?gemeinde=Köflach&ez=1 Example: /api/v1/search/ez?kg=75414&ez=67&wait=0 (answer now, don't wait on repair) GET /api/v1/search/parcel Parcel search with powerful filters. Optional: id (exact parcel_id), kg (KG code), status (E=Erstmaßnahme, G=Grundstück), min_area, max_area (sqm), has_buildings (true|false), landuse (code or abbr), limit, offset, format At least one parameter required. Example: /api/v1/search/parcel?kg=75414&min_area=1000&has_buildings=true GET /api/v1/search/feature Universal feature lookup by any ID. Required: id (parcel_id like "75414-1314/1", building_id, or KG code) Tries: parcels → buildings → KG lookup. Example: /api/v1/search/feature?id=75414-1314/1 GET /api/v1/search/district Browse the administrative hierarchy: states → districts → KGs. Optional: q (text search), state (filter by state name), code (district code), limit, offset, format If state given: lists districts in that state. If code given: lists KGs in that district. If q given: text search across district names. ⚠️ Note: Districts 322 and 325 both have name "Waidhofen an der Thaya" (historical merger) This means /search/district?state=Niederösterreich will list it TWICE When querying KGs, both will return different subsets (173 + 353 = 526 total KGs) Response includes kg_count per district for validation. Example: /api/v1/search/district?state=Steiermark GET /api/v1/search/kg Katastralgemeinde search with multiple filters. Optional: q (text search), code (exact KG code), gemeinde (name filter), district (name filter), plz (postal code via plz_kg mapping), limit, offset, format ⚠️ IMPORTANT LIMITS: • Default limit=100, max recommended=500 • Large districts exceed 200 KGs: Melk (273), Sankt Pölten Land (359), Krems Land (211) • District "Waidhofen an der Thaya" spans TWO codes (322: 173 KGs, 325: 353 KGs = 526 total) Use district_code filter or increase limit to get all KGs • district filter uses LIKE '%name%' → "Wien" matches "Wiener Neustadt" too Use exact match or query by district_code via /search/district first To get ALL KGs: 1. Loop through states → /api/v1/search/district?state={name} 2. For each district: /api/v1/search/kg?district={name}&limit=500 3. Or query by gemeinde_code for precise results Example: /api/v1/search/kg?district=Voitsberg&limit=500 Example: /api/v1/search/kg?plz=8580 Example: /api/v1/search/kg?gemeinde=61630 (by gemeinde_code, most precise) GET /api/v1/search/protected_area Search Austria's 43 WDPA protected areas. Does NOT require the search index (in-memory data). Optional: q (text search in name/designation), near_lon + near_lat (sort by proximity), contains_lon + contains_lat (point-in-polygon test), limit, offset, format Properties: name, desig, desig_eng, desig_type, iucn_cat, rep_area, gis_area, realm format=geojson returns full polygon geometries. Example: /api/v1/search/protected_area?q=Nationalpark Example: /api/v1/search/protected_area?contains_lon=16.7&contains_lat=48.1 GET /api/v1/search/municipalities Search Austrian municipalities — Statistik Austria, 2114 Gemeinden with official names. Preferred over /search/gadm (GADM has ~587 names with missing spaces). Does NOT require the search index (in-memory data). Optional: q (text search in name or code), id (exact 5-digit Gemeindekennziffer), state (filter by state name), district (3-digit Bezirk code), contains_lon + contains_lat (point-in-polygon: which municipality contains this point?), list=all (return all 2114 municipalities, no filter required), limit, offset, format Properties: gemeinde_code, name, district_code, district_name, state, lon, lat, source format=geojson returns full polygon geometries. Example: /api/v1/search/municipalities?q=Gerasdorf Example: /api/v1/search/municipalities?district=617 Example: /api/v1/search/municipalities?list=all&limit=5000 Example: /api/v1/search/municipalities?contains_lon=16.37&contains_lat=48.21 GET /api/v1/search/gadm Search GADM v4.1 municipalities (2100 Austrian Gemeinden with polygons). Note: ~587 names have missing spaces. Prefer /search/municipalities. Does NOT require the search index (in-memory data). Optional: q (text search in name/state/district), gid (exact GID_3), contains_lon + contains_lat (point-in-polygon), limit, offset, format Properties: gid_3, name_3 (municipality), name_2 (district), name_1 (state), type_3 format=geojson returns full polygon geometries. Example: /api/v1/search/gadm?q=Wien Example: /api/v1/search/gadm?contains_lon=16.37&contains_lat=48.21 GET /api/v1/search/address_osm Geocode addresses/place names via OpenStreetMap Nominatim (Austria only). Required: q (address or place name, e.g. "Innsbruck Hauptplatz") Optional: limit (1-50, default 5), offset, format, fields Returns: display_name, lon, lat, osm_type, osm_id, class, place_type, importance, address (road, house_number, city, municipality, state, postcode), bbox, nearest_kg (cross-referenced from spatial index) Useful for finding coordinates from common address names not in cadastre records. Rate-limited to 1 req/s per Nominatim policy. Example: /api/v1/search/address_osm?q=Innsbruck+Hauptplatz Example: /api/v1/search/address_osm?q=Stephansplatz+Wien&limit=3 ### LOOKUP — EDM register autocomplete GET /api/v1/lookup Fast, diacritics-insensitive search across Austria's federal register (EDM). Covers 2,092 Gemeinden, 7,850 KGs, 16,988 Ortschaften, 2,236 PLZ. Does NOT require the search index (in-memory data). Required: q (search text — name, PLZ, code; "Kofla" matches "Köflach") OR one of the shorthand params below. Shorthand: kg=|plz=|gemeinde=|ortschaft= is equivalent to q=&type=. So /api/v1/lookup?kg=63349 just works. Optional: type (plz|gemeinde|kg|ortschaft), limit (default 20, max 200) Returns: array of {type, code, name, plz[], gemeinde_code, gemeinde_name, kg_code, kg_number, location} KG CODE FORMAT: kg_code (and code, for type=kg) is always the canonical 5-digit ZERO-PADDED form — "01503", not "1503" — so it can be pasted verbatim into ?kg=, into parcel_ids ("01503-601/1") and footprint_ids ("01503_fp_9"). kg_number is the BEV Katastralgemeindenummer, always 5 digits, and equals kg_code. (The underlying register file stores an UNPADDED kg_code for 740 of the 7,850 KGs; before Aug 2026 this endpoint passed that through, and feeding it back into /api/v1/query?kg= silently matched 0 parcels.) Both spellings are now accepted on input everywhere — see "KG code format". Scoring: exact code match > name prefix > shortest match. All tokens must match (AND). Numeric gemeinde_code lookup: ?q=61630&type=kg returns all KGs in that Gemeinde. Example: /api/v1/lookup?q=8153 Example: /api/v1/lookup?q=Geistthal Example: /api/v1/lookup?q=Kofla Example: /api/v1/lookup?q=Wien&type=gemeinde Example: /api/v1/lookup?q=Innere+Stadt&type=ortschaft Example: /api/v1/lookup?q=61630&type=kg Example: /api/v1/lookup?kg=63349 (shorthand) Example: /api/v1/lookup?plz=8580 (shorthand) Example: /api/v1/lookup?gemeinde=Köflach (shorthand) ### EXPORT — Download data as GeoPackage or GeoJSON GET /api/v1/export/gpkg Export cadastre data as a multi-layer GeoPackage file. Requires ogr2ogr (gdal-bin) installed on the server. Selectors (at least one required): kg (KG code, comma-separated for multiple), gemeinde (name), gemeinde_code, district (name), state (name), plz (postal code) Layers in output: parcels, buildings, building_footprints, labels, landuse_polygons, landuse_points (same as kg_to_gpkg.sh) Cross-boundary footprints and landuse polygons are deduplicated. Downloads KG data from Zenodo if not locally available. Example: /api/v1/export/gpkg?kg=63349 Example: /api/v1/export/gpkg?gemeinde=Köflach Example: /api/v1/export/gpkg?district=Voitsberg GET /api/v1/export/geojson Export cadastre data as JSON with full polygon geometries from KG source files. Same selectors as GPKG: kg, gemeinde, gemeinde_code, district, state, plz Optional: layers (comma-separated, default: parcels) Available layers: parcels — cadastral parcels with ownership data buildings — address points building_footprints — building polygon geometries labels — parcel number labels landuse — mixed Point/Polygon landuse features (backwards compatible) landuse_polygons — POLYGON-ONLY landuse features with computed area_sqm landuse_points — POINT-ONLY landuse centroids (all features) include_geometry (true|false, default true — false returns centroid Points instead of polygons) Single layer → GeoJSON FeatureCollection (Content-Type: application/geo+json) Multiple layers → JSON object with a FeatureCollection per layer key Parcel features include enrichment data: buildings_on_parcel, landuse_on_parcel, landuse_summary Landuse polygon area_sqm calculated on-the-fly if not stored (no reprocessing needed) Downloads KG data from Zenodo if not locally available. Example: /api/v1/export/geojson?kg=63349&layers=parcels Example: /api/v1/export/geojson?kg=63349&layers=landuse_polygons (ML training ground truth) Example: /api/v1/export/geojson?gemeinde=Köflach&layers=parcels,buildings Example: /api/v1/export/geojson?gemeinde_code=61630&include_geometry=false ### QUERY & STATS — Combined filters and aggregate statistics GET /api/v1/query Most powerful endpoint. Combines all filter parameters with aggregate statistics. Optional: q (text search), kg, gemeinde (name or numeric gemeinde_code), district, state, plz, landuse (code or abbr), min_area, max_area (sqm), has_buildings (true|false), status (E|G), ez, has_legal_refs (true|false), legal_context (e.g. national_park, viticulture), has_natura2000 (true|false) — parcel centroid in any Natura 2000 site, natura2000_site (sitecode, e.g. AT1205A00) — restrict to one site, natura2000_type (A|B|C) — A=Birds/SPA, B=Habitats/SCI-SAC, C=both, natura2000_habitat (tag) — moor|floodplain|river|lake|wetland|forest|alpine| meadow|pasture|valley|hill|steppe|orchard|park|cave, with_stats (true|false, default false when a natura2000 filter is active), min_lon + min_lat + max_lon + max_lat (bbox filter), sort (area_desc|area_asc|gnr, default area_desc), limit, offset, format At least one filter required. Results include legal_ref_count, legal_contexts, and legal_refs when legal references exist. gemeinde accepts both name ("Köflach") and numeric code (61630). Returns: { data: [...parcels...], meta: {...}, stats: { matching_parcels, total_area_sqm, total_area_ha, avg_area_sqm, min_area_sqm, max_area_sqm, total_buildings, landuse_breakdown: [...] }} Aggregate semantics (stats block): - stats.skipped + stats.skipped_reason always explain WHY something is absent. - Aggregates are skipped without a selective filter (kg/gemeinde/district/ state/plz/ez/q/landuse/bbox): unfiltered COUNT(DISTINCT) over 10.1M parcels takes ~30 s. Use /api/v1/stats for national totals, or with_stats=true. - landuse_breakdown is gated at 200,000 matching parcels (it is ~7 s on a state-sized set). Use /api/v1/landuse/distribution, narrow the filter, or pass with_stats=true. - For wide filters (>=250 KGs, i.e. a state or large region) the numeric aggregates are served stale-while-revalidate: the previous snapshot is returned instantly and refreshed in the background (they only change when a KG is reprocessed). skipped_reason then names the snapshot age. Pass refresh=1 to recompute synchronously, or with_stats=true to bypass the memo. Narrow filters are always computed live. Effect: ?state=Steiermark&limit=100 went 9.06 s -> 1.6 s -> 15 ms warm. Example: /api/v1/query?plz=8580&landuse=W&min_area=1000 Example: /api/v1/query?gemeinde=61630 Example: /api/v1/query?district=Voitsberg&has_buildings=true&sort=area_desc Example: /api/v1/query?state=Kärnten&legal_context=viticulture&limit=20 Example: /api/v1/query?has_legal_refs=true&legal_context=national_park Example: /api/v1/query?has_natura2000=true&limit=20 Example: /api/v1/query?natura2000_site=AT1205A00 (parcels in Wachau Habitats site) Example: /api/v1/query?natura2000_type=A&limit=20 (parcels in Birds Directive SPAs) Example: /api/v1/query?natura2000_habitat=moor&limit=20 (moor parcels) Example: /api/v1/query?natura2000_habitat=floodplain&state=Wien (Vienna floodplain parcels) Example: /api/v1/query?has_natura2000=true&state=Tirol&landuse=W GET /api/v1/query/protected_area Spatial query: find parcels within, or near, a protected area polygon (WDPA). Uses actual polygon geometry (point-in-polygon on parcel centroids), not just legal references. Required: area (protected area name substring, e.g. "Kalkalpen", "Donau-Auen", "Gesäuse") Optional: relation (within|near, default within), buffer_m (metres, for relation=near), plus all /query filters: landuse, min_area, max_area, has_buildings, status, ez, has_legal_refs, legal_context, sort (area_desc|area_asc|gnr|distance), limit, offset, format Each result includes: protected_area_relation: "within" or "near" legal_status: explains whether the parcel is referenced by name in law (RIS) vs spatially inside the polygon (WDPA): "both" = legally named AND spatially inside "spatially_contained" = inside polygon but not named in any known law "legally_named" = named in law but centroid outside polygon (edge parcels) "spatially_near" = within buffer distance but not inside distance_to_boundary_m: (only for near parcels) distance to nearest polygon edge IMPORTANT: legal_status="legally_named" means the parcel is explicitly mentioned in Austrian law. "spatially_contained" means the parcel falls inside the WDPA polygon boundary but may not be mentioned in legislation — other laws may include parcels on a map without naming them. Stats include: parcels_within, parcels_near, legally_named counts, matched protected_areas list. Example: /api/v1/query/protected_area?area=Kalkalpen&relation=within Example: /api/v1/query/protected_area?area=Donau-Auen&relation=near&buffer_m=1000 Example: /api/v1/query/protected_area?area=Gesäuse&has_buildings=true Example: /api/v1/query/protected_area?area=Hohe+Tauern&relation=near&buffer_m=500&landuse=W GET /api/v1/stats Global index statistics. No parameters needed. Returns: total parcels, buildings, KGs, states covered, districts covered, total area (sqm + km²), landuse type count, per-state breakdown with district_count, kg_count, parcel_count, building_count, total_area_sqm. V2 fields: footprint_count, urban_kgs, suburban_kgs, rural_settled_kgs, rural_agrarian_kgs, avg_building_density_per_km2, avg_built_up_ratio. Example: /api/v1/stats GET /api/v1/landuse/codes Reference table of all Austrian cadastre landuse type codes. No parameters needed. Returns: array of { code (2-digit NS code), type_name (German), abbr (abbreviation) } Codes 40-97 covering: Baufläche (B), Verkehr (V), Wald (W), Landwirtschaft (LN), Gewässer (GW), Weingarten (WG), Gartenanlage (GA), Alpe, Ödland, etc. Example: /api/v1/landuse/codes GET /api/v1/landuse/distribution Landuse distribution aggregated by geography. Optional: kg (KG code), gemeinde, district, state (geographic filters), code (landuse code), abbr (landuse abbreviation), group_by (kg|gemeinde|district|state, default kg), limit, offset, format Returns: aggregated polygon_count, point_count, total_area_sqm per landuse per area. total_area_sqm is computed from actual polygon geometries during indexing (not parcel areas). Example: /api/v1/landuse/distribution?kg=63349 Example: /api/v1/landuse/distribution?district=Voitsberg Example: /api/v1/landuse/distribution?code=56&state=Steiermark ### PROCESSING POST /api/inspire/process/{kg_code} Process a Katastralgemeinde from BEV vector tiles. KG codes are 5-digit numbers (e.g. 63349, 75414). Optional: max_age (seconds, default 31536000 = 1 year). If a local or Zenodo file exists younger than max_age, reprocessing is skipped. ?max_age=0 forces reprocessing. VERSIONING: each successful run archives an immutable dated snapshot on Zenodo ({kg}-YYYY-MM-DD.json.gz) and refreshes the canonical "latest" file. Older states are never deleted, so a reprocess ADDS a version. Takes a few minutes per KG (BEV tile download dominates). GET /api/v1/kg_versions[?kg={kg_code}] Archived dated snapshots per KG. Without ?kg: totals. With ?kg: the list of dates plus per-snapshot filename, size, md5, parcel_count, processing_version and which one is "latest". The date is the DATA's processing date (processing_metadata.processed_at), not the upload time, so it identifies the cadastre state. Retrieve a specific state on the server with: ./cadastre-server -zenodo download-version -kg 65129 -version 2026-03-24 Example: /api/v1/kg_versions?kg=65129 GET /api/inspire/status/{kg_code} Processing status for a KG. GET /api/inspire/list List all processed KGs. ### ZENODO GET /api/zenodo/status Zenodo mirror status and manifest. ### FEEDBACK — Bug reports and feature requests from LLM agents POST /api/v1/feedback Submit a bug report, feature request, or improvement suggestion. Content-Type: application/json Required: title (string) Optional: agent (string — your agent name/identifier, e.g. "invekos-matcher-v2") category (bug|feature|improvement|question|other, default: other) severity (critical|high|normal|low, default: normal) description (string — detailed explanation, reproduction steps, expected vs actual behavior) endpoint (string — which API endpoint is affected, e.g. "/api/v1/export/gpkg") context (object — any structured data: request params, error messages, version info, etc.) Response: {"status": "created", "id": 42, "message": "Feedback #42 recorded. Thank you!"} Example body: {"agent": "field-mapper", "category": "bug", "severity": "high", "title": "GPKG export timeout for large districts", "description": "Exporting district=Voitsberg takes >60s and times out", "endpoint": "/api/v1/export/gpkg", "context": {"district": "Voitsberg", "kg_count": 45}} GET /api/v1/feedback List feedback entries. Optional filters: status (open|acknowledged|in_progress|resolved|wontfix), category, severity, agent, limit (default 100, max 1000) Response: {"feedback": [...], "meta": {"total": N, "open": N}} Example: /api/v1/feedback?status=open Example: /api/v1/feedback?category=bug&severity=high ### DIAGNOSTICS — Is the API healthy / why is my call slow? GET /api/v1/debug/inflight Currently-executing requests with their ages, plus goroutine count and database/sql pool stats. Use this first when a call seems slow or stuck. Response: {"inflight_count": N, "requests": [{"method", "url", "age_secs"}, ...], "goroutines": N, "db": {"open_connections", "in_use", "idle", "max_open", "wait_count", "wait_duration_sec"}} Interpretation: - Your own request is the only one listed and it's young => the API is idle, the cost is in your query (widen limit / narrow bbox / use ?geometry=0). - db.wait_count climbing and in_use == max_open => connection contention; reduce your client-side concurrency and prefer batch endpoints. - Many old entries for OTHER URLs => someone else is loading the service. Example: /api/v1/debug/inflight GET /debug/pprof/... Standard Go pprof endpoints (goroutine, heap, profile, trace). Mainly for operators; adding ?debug=2 to goroutine gives a human-readable stack dump. Example: /debug/pprof/goroutine?debug=2 Example: /debug/pprof/heap Cache-readiness endpoints (see the ready:false note at the top of this file): GET /api/v1/spatial/geom_cache/stats — parcel-geometry cache coverage GET /api/v1/footprints/geom_cache/stats — footprint-geometry cache coverage GET /api/v1/footprints/link/stats — footprint↔parcel link coverage (aggregates memoised 5 min; ?refresh=1) GET /api/v1/buildings/repair/stats — address-point re-join progress GET /api/v1/osm/stats — OSM proximity coverage GET /api/v1/prewarm/stats — cold-KG coverage over the set of KGs clients actually request ### LEGAL REFERENCES — RIS parcel-to-law mappings GET /api/v1/legal/kg/{kg_code} All legal parcel references for a Katastralgemeinde. Returns: refs array, total_refs, unique_laws, legal_contexts. Optional filters: context (legal_context value), type (listed|boundary_walk), law (substring match on law name), limit, offset Example: /api/v1/legal/kg/49006 Example: /api/v1/legal/kg/49006?context=national_park Example: /api/v1/legal/kg/49006?law=Kalkalpen&type=listed GET /api/v1/legal/parcel/{kg_code}/{gnr} Legal refs for a specific parcel. GNR may contain / (e.g. 213/1). Returns: refs array, total_refs, kg_code, grundstueck, parcel_id. Example: /api/v1/legal/parcel/49006/213/1 GET /api/v1/legal/stats Summary statistics across all legal refs. Returns: total_refs, unique_kgs, unique_parcels, unique_laws, by_legal_context (counts per context), by_parcel_list_type (listed vs boundary_walk), by_bundesland (counts per state) Example: /api/v1/legal/stats GET /api/v1/legal/search Search legal references across all fields. Optional: q (free-text across law names, KG names, parcel numbers, context, Bundesland), context (exact legal_context), type (listed|boundary_walk), bundesland (substring), kg (exact KG code), limit, offset At least one parameter required. Returns: refs array, total_refs, unique_kgs, unique_laws, by_legal_context. Example: /api/v1/legal/search?q=Kalkalpen Example: /api/v1/legal/search?context=water_protection&bundesland=Ober%C3%B6sterreich Example: /api/v1/legal/search?type=boundary_walk&kg=50207 Legal context values: national_park, nature_protection, landscape_protection, nature_park, water_protection, species_protection, admin_boundary, zoning, monument_protection, viticulture, hunting, forestry, other Parcel list types: listed (explicitly named in law), boundary_walk (mentioned in boundary description) Enrichment: existing search endpoints are enriched with legal data: - /api/v1/search/parcel results include legal_refs array (per parcel) - /api/v1/search/kg results include legal_ref_count and legal_contexts - /api/v1/search/ez results include legal_ref_count and legal_contexts (summary and per-parcel) ### NATURA 2000 — EU protected sites overlay (353 AT sites, ~896k parcels) GET /api/v1/natura2000/stats Summary statistics. No parameters. Returns: total_sites (353), total_area_ha, by_type_count {A:47,B:253,C:53}, by_type_area_ha, by_type_parcel_count {A:~466k,B:~379k,C:~196k}, by_habitat_site_count {forest, alpine, hill, lake, valley, floodplain, steppe, moor, river, meadow, pasture, wetland, park, …}, by_habitat_site_area_ha, by_habitat_parcel_count, kgs_with_overlap (5571 by bbox), kgs_with_parcels_in_n2k (~2786), parcels_in_natura2000 (~896469), parcel_cache_ready (bool), parcel_cache_building (bool), top_sites_by_parcel_count (top 10). Site types: A = Birds Directive (SPA), B = Habitats Directive (pSCI/SCI/SAC), C = both. Habitat tags are inferred from the German SITENAME at index time and stored in SQLite table natura2000_parcel_habitats(parcel_id, habitat). Available: moor, floodplain, river, lake, wetland, forest, alpine, meadow, pasture, valley, hill, steppe, orchard, park, cave. GET /api/v1/natura2000/search Substring search on sitename or sitecode. Optional: q, type (A|B|C), list=all (return everything), limit, offset. Example: /api/v1/natura2000/search?q=Wachau Example: /api/v1/natura2000/search?list=all&limit=400 GET /api/v1/natura2000/site/{sitecode} Single site details. Optional: geometry=1 to include MultiPolygon coordinates. Example: /api/v1/natura2000/site/AT1205A00 Example: /api/v1/natura2000/site/AT1205A00?geometry=1 GET /api/v1/natura2000/site_parcels/{sitecode} Paginated list of parcels whose centroid lies inside the given site. Sorted by area_sqm DESC. Optional: limit, offset. Example: /api/v1/natura2000/site_parcels/AT1205A00?limit=50 GET /api/v1/natura2000/point?lon=&lat= Ad-hoc point-in-polygon test. Returns all sites containing the point. Example: /api/v1/natura2000/point?lon=15.45&lat=48.36 GET /api/v1/natura2000/parcel/{kg_code}/{gnr} Returns the sites containing the parcel's centroid. Empty sites array if outside. Example: /api/v1/natura2000/parcel/12358/482 GET /api/v1/natura2000/kg/{kg_code} KG-level overlap: list of sites whose bbox intersects this KG, plus exact parcel_count (parcels with centroid in any site). Example: /api/v1/natura2000/kg/12358 GET /api/v1/natura2000/kgs All KGs with >=1 parcel inside any Natura 2000 site. Per-KG parcel_count + site_codes. Optional ?site={sitecode}, ?sort=parcels|kg, limit/offset. Example: /api/v1/natura2000/kgs?limit=20 Enrichment in existing endpoints (no rebuild required): - /api/v1/search/parcel results include in_natura2000 (bool) and natura2000_sites[] (per parcel) - /api/v1/search/kg results include both a bbox-superset (natura2000_overlap_count, natura2000_site_codes, natura2000_site_names) and an exact-containment subset (natura2000_inside_count, natura2000_inside_site_codes, natura2000_inside_site_names, natura2000_inside_sites[].parcel_count) plus near_only_* counterparts. legacy natura2000_overlap_count, natura2000_parcel_count, natura2000_site_codes[], natura2000_site_names[] - /api/v1/search/ez parcel rows enriched the same way as /search/parcel Filtering via /api/v1/query: - has_natura2000=true → parcels inside any Natura 2000 site (~896k) - has_natura2000=false → parcels outside all sites - natura2000_site=AT1205A00 → only parcels inside that specific site - natura2000_type=A|B|C → parcels in sites of the given directive type (joins natura2000_parcel_index covering partial index has_a|has_b|has_c) - natura2000_habitat= → parcels in sites classified as that habitat (joins natura2000_parcel_habitats; tag from the vocabulary above) Backed by indexed SQLite cache tables; responses are typically <100 ms even when matching hundreds of thousands of parcels. Aggregate stats are skipped by default for Natura 2000 filters — pass with_stats=true to force them. Habitat enrichment: each natura2000_sites[] entry returned by parcel/EZ endpoints now includes a habitats[] array (e.g. ["moor"], ["floodplain", "river"]). Use these tags to power UI badges or coarse-grain habitat queries without rebuilding the search index. Data source: EEA Natura2000Sites MapServer, layer 2 (combined). Refresh with: curl 'https://bio.discomap.eea.europa.eu/arcgis/rest/services/ProtectedSites/Natura2000Sites/MapServer/2/query?where=MS%3D%27AT%27&outFields=SITECODE,SITENAME,MS,SITETYPE,Area_ha&f=geojson&outSR=4326&returnGeometry=true' | gzip > data/natura2000_at.geojson.gz Then delete data/search_index.db tables natura2000_parcels + natura2000_parcel_index + natura2000_parcel_habitats and restart (the per-parcel cache rebuild takes ~5 minutes; the in-memory bbox lookups are instant). The loader also auto-rebuilds when it detects an outdated schema (missing sitetype/has_a columns), so an upgrade alone is enough — no manual SQL needed. ### OSM PROXIMITY — roads, rail, public transport, water, remoteness Per-parcel accessibility metrics derived from the OpenStreetMap Geofabrik Austria extract (roads, railways, transit stops, waterways/lakes, places), stored in a dedicated SQLite DB (data/osm_proximity.db) with R*Tree indexes. Completely separate from the search index — zero write contention, no index rebuild ever needed. Distances in metres from the parcel centroid. Metric fields (the "osm" block): dist_road_m + road_fclass + road_name — nearest drivable road road_on_parcel (bool) — nearest road within the parcel's equivalent-circle radius (heuristic for "a road runs over this parcel") dist_major_road_m + major_road_fclass/name/ref — nearest motorway/trunk/ primary/secondary (e.g. ref "B70", "A2") dist_rail_m — nearest railway line (any type) dist_transit_m + transit_fclass/name — nearest public-transport stop (bus_stop, bus_station, tram_stop, railway_station, railway_halt, ferry_terminal) dist_train_station_m + train_station_name — nearest railway_station/halt dist_water_m + water_fclass/name — nearest river/stream/canal line or lake/reservoir shoreline dist_settlement_m + settlement_fclass/name — nearest OSM place node (village/town/city) remoteness (0..100) — composite score; 0 = central, 100 = remote. Weighted sqrt-normalised: road/5km w=.30, major road/20km w=.15, transit/10km w=.30, settlement/15km w=.25 Enrichment (automatic, cached-only fast path): /api/v1/search/parcel, /api/v1/search/ez and /api/v1/query parcel rows gain an "osm": {…} block when the parcel's metrics are cached. On a cache miss the row is returned without the block and the whole KG is warmed in the background (continuous request warming) — repeat the query a few seconds later. A full-country backfill precomputes all ~10.1M parcels. GET /api/v1/osm/parcel/{parcel_id} Metrics for one parcel. Guaranteed answer: computes live on cache miss (few ms) and persists + warms the KG. Example: /api/v1/osm/parcel/63349-348/6 GET /api/v1/osm/point?lon=&lat= Ad-hoc metrics for any coordinate (not persisted). Example: /api/v1/osm/point?lon=15.0857&lat=47.0665 GET /api/v1/osm/geometry Raw OSM feature geometries as a GeoJSON FeatureCollection so clients can draw the context fast (roads/rail/water lines, transit/place points). Selection (one of): ?kg=63349 — KG bbox ?bbox=west,south,east,north — explicit bbox (EPSG:4326) ?parcel=63349-348/6&buffer_m=500 — bbox around a parcel (default 500 m) Filters: ?cat=road,rail,water,transit,place (default road) ?major=1 (roads: only motorway/trunk/primary/secondary) ?limit=N (default 20000, max 100000; meta.truncated) Line properties: cat, fclass, name, ref, major. Point properties: cat, fclass, name, train_station, population. Lines are served as indexed chunks (≤24 vertices); consecutive chunks of the same way share endpoints. Example: /api/v1/osm/geometry?kg=63349&cat=road,transit&major=1 Example: /api/v1/osm/geometry?parcel=63349-348/6&buffer_m=300&cat=road,water GET /api/v1/osm/stats Import/cache status: ready, line_chunks, points, by_category, parcel_metrics_cached, total_parcels, pct_cached, meta.imported_at. Ops: refresh source with the Geofabrik download (austria-latest-free.gpkg.zip → data/osm_geofabrik_data/austria.gpkg), then run "cadastre-server -osm-import" (rebuilds lines/points; parcel metrics stay) and optionally "cadastre-server -osm-backfill" to precompute all parcels. Both run as separate processes — the live API keeps serving. ### FOOTPRINT ↔ PARCEL LINK — which footprint sits on which parcel(s) A SQLite-backed bidirectional lookup, built once from the locally-present data/cadastre_json/*.json.gz files. No search-index rebuild is required. Reloads instantly on subsequent restarts. **This is the authoritative mapping** — the parcel.footprints[] field inside the cadastre JSON files contains stale per-parcel sequence IDs that do not match the actual building_footprints[*].footprint_id values, so use these endpoints instead. A footprint may span multiple parcels along boundaries; the centroid- containing parcel is marked primary_parcel=true and listed first. GET /api/v1/footprints/{footprint_id}/parcels Returns every parcel intersecting the given footprint. Fields per parcel: parcel_id, kg_code, overlap_sqm (centroid-containing parcel uses the full footprint area; neighbours get an approximate vertex-share area), primary_parcel (bool). Example: /api/v1/footprints/01002_fp_9/parcels GET /api/v1/parcels/{parcel_id}/footprints Returns every footprint intersecting the given parcel, joined with V2 footprint metrics (area_sqm, size_class, shape_class, ns_code). Example: /api/v1/parcels/01002-1219/footprints GET /api/v1/buildings/repair/stats Address-point repair progress. Building lon/lat used to be geocoded from the address STRING, so two buildings with the same house number in different KGs collapsed onto ONE coordinate while each kept its own parcel_id/ez. Every address point is now joined to the parcel that geometrically CONTAINS it. Fields: kgs_total, kgs_repaired, kgs_pending, pct_repaired, address_points, inside_parcel, snapped_to_parcel (<=30 m from its parcel boundary), unlinked, dropped_foreign (tile-overlap copies of a neighbouring KG's address point), pct_inside_parcel, repair_version. Building rows everywhere carry coords_verified: true once their KG is repaired. Repair is lazy (any building query warms its KG; single-object lookups wait up to 3 s) and can be forced in bulk with ./cadastre-server -repair-buildings. STATUS: as of 2026-08-05 this backlog is COMPLETE — 7850/7850 KGs, kgs_pending 0, so coords_verified is true everywhere. A reprocessed KG can briefly go pending again; force it with POST /api/v1/buildings/repair?kg=&wait=. GET /api/v1/prewarm/stats Cold-KG coverage. Every geometry/link/repair cache is lazy per KG, so the FIRST touch of a KG whose json.gz is not on disk pays a ~2 s Zenodo download + parse (measured: /export/geojson?kg=01002 8.4 s cold vs 0.17 s warm). To make that targetable rather than guesswork, the API records which KGs clients actually request (table kg_access, batched writes, no per-request IO) and this endpoint reports how much of that real access set is already warm. Fields: accessed_kgs, fully_warm, needs_warming, top_cold_kgs[], local_kg_files, pending_access_writes, top_requested[] ({kg_code, hits, last_seen, warm}), disk{} (see below). disk{}: the LRU eviction ceilings and current free space — cadastre_json_budget_gb (the *.json.gz dir; /export/geojson reads those FILES, not the R-tree caches, so this budget decides whether a warmed KG stays warm), parcel_geom_budget_gb, footprint_geom_budget_gb, min_free_gb, free_gb, under_pressure, holds_full_country, full_country_json_est_gb. All three are sized to hold every Austrian KG (~34 GB of json.gz, ~3.9 GB parcel geom, ~3.6 GB footprint geom), so in steady state nothing is evicted. Eviction still triggers on real disk pressure (free_gb < min_free_gb), and then only frees the deficit. "Warm" means parcel geometry at the current content version + footprint geometry + repaired building coordinates, read from SQLite so the answer is correct across processes. Warm the cold set (operator action, safe alongside the live service): ./cadastre-server -prewarm-kgs # everything clients asked for ./cadastre-server -prewarm-kgs -prewarm-limit 200 # the 200 most-requested ./cadastre-server -prewarm-kg-list 63349,80110 # an explicit set Flags: -prewarm-throttle 150ms (pause between KGs), -prewarm-keep-local (retain downloaded json.gz — needed if you want /export/geojson to be fast too, since it reads the file rather than the caches). The access set only knows what clients have already asked for, so on this deployment the pass is SCHEDULED weekly (systemd cadastre-prewarm.timer, Sun 03:15 ± 30 min, throttled + idle IO so the live API stays responsive). Inspect with: systemctl list-timers cadastre-prewarm.timer; journalctl -u cadastre-prewarm. Example: /api/v1/prewarm/stats?limit=50 GET /api/v1/footprints/link/stats Cache status and counts: ready, building, link_count, footprint_count, parcel_count, multi_parcel_count (footprints spanning >1 parcel), and meta {schema_version, built_at, kg_count, footprint_count, link_count}. SQLite schema (data/search_index.db): footprint_parcel_link(footprint_id, parcel_id, kg_code, overlap_sqm, primary_parcel) PRIMARY KEY(footprint_id, parcel_id) + indexes idx_fpl_parcel(parcel_id), idx_fpl_kg(kg_code), idx_fpl_fp_pri(footprint_id, primary_parcel) footprint_parcel_meta(key, value) — build metadata Rebuild: drop tables footprint_parcel_link + footprint_parcel_meta and restart the service; the rebuild runs in a background goroutine while the API stays responsive. ### PARCEL GEOMETRY — fast per-parcel polygon vertices Per-parcel polygon coordinates + basic attributes served from the same persistent R-tree cache that backs POST /api/v1/spatial/points (WKB + SQLite R*Tree, 12 GB LRU, dir→Zenodo fallback, lazy per-KG warm). No json.gz load or Zenodo round-trip once a KG is warm. GET /api/v1/parcels/{parcel_id}/geometry One parcel: area_sqm, gnr, ez, status, lon, lat, landuse (summary), and the full GeoJSON geometry. Warms the KG on miss (retry after a beat if not found and "ready":false). ?geometry=0 omits the coordinates (attributes only). MULTI-PART PARCELS: geometry is a GeoJSON MultiPolygon (with a "geometry_parts" count) whenever the parcel consists of several detached pieces — common for alpine Grundstücke where one GNR covers multiple alm/wald parcels. Single-part parcels return a plain Polygon. Interior holes are included. This matches /api/v1/export/geojson exactly. If "ready":false the served rows may still come from a pre-fix cache entry (see /api/v1/spatial/geom_cache/stats → multipolygon_migration_pending); retry shortly, or use /api/v1/export/geojson?kg= for a guaranteed-complete answer. Example: /api/v1/parcels/01002-1219/geometry NOTE: parcel_ids whose Grundstücksnummer contains a slash (e.g. 413/31) must be percent-encoded exactly ONCE as %2F in the path segment: /api/v1/parcels/01503-413%2F31/geometry — do NOT double-encode (%252F). The same rule applies to /api/v1/parcels/{parcel_id}/footprints. GET /api/v1/spatial/parcels?west=&south=&east=&north= VIEWPORT MODE: every cached parcel polygon whose bbox intersects the given viewport, straight from the R-tree (bbox-vs-bbox scan, no per-parcel round-trip, no json.gz load). Companion to /api/v1/spatial/bbox, which only returns centroids and no polygon geometry — use this one for map rendering. Aliases minlon/minlat/maxlon/maxlat accepted. Optional: limit (default 5000, max 20000), geometry=0 (omit coordinates). Response: {bbox, count, parcels[] (same shape as the single-parcel endpoint, each with geometry unless geometry=0, plus geometry_parts for multi-part parcels), truncated, limit, ready}. ready=false means some KG intersecting the viewport wasn't cached yet and was just scheduled to warm in the background — retry shortly for the full set. Example: /api/v1/spatial/parcels?west=16.35&south=48.19&east=16.40&north=48.22&limit=2000 GET/POST /api/v1/parcels/geometry/batch Batch version — up to 5000 parcel_ids in one call. Collapses the N+1 pattern of looping the single-parcel endpoint after collecting IDs from GET /api/v1/search/parcel?kg=X (or /search/ez). GET: ?ids=63349-348/6,63349-349 POST: {"ids": ["63349-348/6", "63349-349", ...]} ?geometry=0 omits polygon coordinates (attributes only, smaller payload). Response: {requested, count, parcels[] (same shape as the single endpoint), not_found[] (ids with no cache row), ready}. Warms every distinct KG touched by the request once, up front. Example: /api/v1/parcels/geometry/batch?ids=63349-348/6,63349-349 Recommended chain for "all vertices for a filtered subset of parcels in a KG": 1. GET /api/v1/search/parcel?kg=X&...filters...&limit=5000 → parcel_ids 2. GET /api/v1/parcels/geometry/batch?ids= → vertices For ALL parcels in a KG (no filter), prefer the one-shot bulk export instead: GET /api/v1/export/geojson?kg=X&layers=parcels (reads straight from the KG's json.gz, no cache warm-up needed). ### BUILDING-FOOTPRINT GEOMETRY & SHAPE — oriented dims, orientation, polygon Per-footprint shape analysis: the actual polygon coordinates PLUS the true ORIENTED bounding box (length × width from a minimum-area rectangle computed by rotating-calipers over the convex hull — not the north-aligned bbox), area, perimeter, compactness, vertex count, and the long-axis compass orientation. Backed by a lazy, per-KG, R-tree cache (WKB + SQLite R*Tree, 12 GB LRU, dir→Zenodo fallback) that warms incrementally per KG — no multi-hour reindex. Orientation fields: orientation_deg long-axis bearing, 0–180° (0=N–S, 90=E–W) orientation_axis coarse cardinal label (N–S, NE–SW, E–W, NW–SE) long_side_faces_deg bearing the long façade faces (outward normal) short_side_faces_deg bearing the short façade faces (== orientation_deg) Dimensions: obb_length_m, obb_width_m, obb_elongation (length/width). GET /api/v1/footprints/{id}/geometry One footprint: metrics + oriented dims + GeoJSON polygon. Warms KG on miss. Fields: footprint_id, kg_code, ns_code, area_sqm, perimeter_m, compactness, vertex_count, obb_length_m, obb_width_m, obb_elongation, orientation_deg, orientation_axis, long_side_faces_deg, short_side_faces_deg, lon, lat, geometry (GeoJSON Polygon), found, ready. ?geometry=0 omits the polygon coordinates (metrics only). Example: /api/v1/footprints/01002_fp_0/geometry GET /api/v1/spatial/footprints?lon=&lat= Every footprint whose polygon CONTAINS the point, each with full metrics and oriented dims. Warms candidate KGs on miss. Returns {count, footprints[]}. Example: /api/v1/spatial/footprints?lon=16.3646&lat=48.2387 GET /api/v1/spatial/footprints?west=&south=&east=&north= VIEWPORT MODE (same endpoint, different params): every footprint whose cached bbox intersects the given viewport, straight from the R-tree (bbox-vs-bbox scan, no per-footprint round-trip, no json.gz load). Aliases minlon/minlat/maxlon/maxlat accepted. This is the fast way to draw building footprints on a map as the user pans/zooms. Optional: limit (default 5000, max 20000), geometry=0 (omit coordinates). Response: {bbox, count, footprints[] (same shape as point mode, each with geometry unless geometry=0), truncated, limit, ready}. ready=false means some KG intersecting the viewport wasn't cached yet and was just scheduled to warm in the background — retry shortly for the full set. Example: /api/v1/spatial/footprints?west=16.35&south=48.19&east=16.40&north=48.22&limit=2000 GET /api/v1/parcels/{parcel_id}/footprints?shape=1 The existing parcel→footprints list, each row additionally enriched with obb_length_m, obb_width_m, obb_elongation, orientation_deg, orientation_axis, long_side_faces_deg, short_side_faces_deg, perimeter_m, compactness, vertex_count. Example: /api/v1/parcels/01002-1219/footprints?shape=1 GET/POST /api/v1/footprints/geometry/batch Batch version of /api/v1/footprints/{id}/geometry — up to 5000 footprint_ids in one call. Collapses the N+1 pattern of looping the single-footprint endpoint after collecting IDs from parcels/{id}/footprints. GET: ?ids=63349_fp_1,63349_fp_2,... POST: {"ids": ["63349_fp_1", "63349_fp_2", ...]} ?geometry=0 omits polygon coordinates (metrics only, smaller payload). Response: {requested, count, footprints[] (each same shape as the single endpoint), not_found[] (ids with no cache row), ready}. Warms every distinct KG touched by the request once, up front. Example: /api/v1/footprints/geometry/batch?ids=01002_fp_0,01002_fp_1 GET /api/v1/footprints/geom_cache/stats Cache readiness/budget: ready, kgs_built, bytes_used, budget_bytes, pct_of_budget, schema_version. SQLite schema (data/search_index.db): footprint_geom(id, footprint_id, kg_code, ns_code, wkb, area_sqm, perimeter_m, compactness, vertex_count, obb_length_m, obb_width_m, obb_elongation, orientation_deg, long_side_faces_deg, short_side_faces_deg, lon, lat) footprint_geom_rtree R*Tree(id, min_lon, max_lon, min_lat, max_lat) footprint_geom_kg(kg_code, footprint_count, byte_size, built_at, last_access) footprint_geom_meta(key, value) — schema_version Warming: Lazy on-miss (any footprint query / KG save / Zenodo fetch), dir→Zenodo. ./cadastre-server -warm-geom seed from LOCAL KGs only ./cadastre-server -backfill-footprint-geom KGs in parcel-geom cache ./cadastre-server -backfill-footprint-geom-all every KG in the manifest ./cadastre-server -backfill-footprint-geom-keep-local ./cadastre-server -backfill-footprint-geom-throttle 500ms (default 200ms) Rebuild: bump footprintGeomSchemaVersion (auto drop+rebuild) or drop tables footprint_geom* and restart. ### LAND PRICES — Statistik Austria-calibrated per-parcel price & rent Per-parcel predicted purchase value (€/m²) and — for agricultural land — annual rent (€/ha/year), calibrated against the published Statistik Austria averages (Baugrundstückspreise per Gemeinde 2015–2024, Boden- and Pachtpreise per Bundesland 2022–2024). Classification (NS landuse alone is not trusted): has_buildings/footprints → bauland_built NS ∈ {40-47, 91} & unbuilt → bauland_zoned NS ∈ {51, 62} → ackerland NS ∈ {50, 52-55, 58, 61} → gruenland NS ∈ {56, 57} → wald else → other (Verkehr, Gewässer, Fels, …) Bauland prediction: price = SA_gemeinde_baseline(year) × calibration × f_size × f_shape × f_typology f_size smaller plots fetch a per-m² premium (reference 600 m² lot) f_shape compactness (1=square/circle) → +up to 15% f_typology KG typology (urban 1.30 / suburban 1.08 / rural 0.92–0.78) calibration global constant (~1.10) keeping geometric means aligned Temporal handling of the year axis: • exact year hit → use that SA observation • gap inside series → log-linear interpolation between bracketing years • outside series → extrapolate via per-gemeinde log-linear CAGR (clipped − 4 % … +18 %); state-level CAGR is the fallback when the gemeinde series is too short. Validation: 89% of gemeinden within ±25% of SA value for 2024; 89% for 2019 (gap year, interpolated). All within ±50%. State CAGRs derived from the published series (2016–2024): Burgenland +7.3% · Kärnten +4.9% · NÖ +6.5% · OÖ +8.4% · Salzburg +8.3% Steiermark +6.5% · Tirol +7.5% · Vorarlberg +8.4% · Wien +3.1% · AT +7.2% Agricultural prediction: SA Bundesland baseline (Ackerland or Grünland) applied directly. The same log-linear interpolation / state-CAGR extrapolation is used on the year axis. Aggregated back to state means equals SA by construction for years inside the published range. Rent = SA Bundesland EUR/ha/year(year) × area_sqm / 10000. AT CAGRs: ackerland buy +4.2 %/yr, grünland buy +1.6 %/yr, ackerland rent +0.8 %/yr, grünland rent +3.1 %/yr. Wald: indicative literature range per Bundesland (SA does not publish). Other: no market price assigned. GET /api/v1/land_prices/stats Documentation, calibration constant, source meta, endpoint list, per-state Bauland CAGR (bauland_cagr_by_state), per-state agricultural CAGR (agri_cagr_by_state), latest_source_year. GET /api/v1/land_prices/parcel/{parcel_id} Per-parcel estimate. Returns class, area_sqm, buy_eur_per_sqm, buy_total_eur, rent_eur_per_ha_year, rent_eur_per_year, baseline_eur_per_sqm, baseline_source (gemeinde|bezirk|state|national|literature_state), baseline_year, factors {size, shape, typology}, calibration, confidence. Example: /api/v1/land_prices/parcel/01002-1219 Optional: ?year=YYYY (default 2024) GET /api/v1/land_prices/gemeinde/{gemeinde_code} Municipality aggregate: per-class parcel count, total area, total value, mean & geometric mean buy price, annual rent total. Includes a validation block comparing the predicted Bauland geometric mean to the published Statistik Austria value (sa_bauland_eur_per_sqm, predicted_geomean_eur_per_sqm, predicted_minus_sa_pct). When parcels carry footprints, also returns a 'buildings' block with total_footprint_area_sqm and total_value_eur (sum of building values). Example: /api/v1/land_prices/gemeinde/61630 Optional: ?year=YYYY GET /api/v1/land_prices/bezirk/{district_code} Bezirk (3-digit Bezirksnummer) aggregate. Returns: - gemeinden[] list of municipalities in the Bezirk - statistik_austria Bauland/Haus/Wohnung SA published prices for the Bezirk - by_class per-class parcel aggregates (omit with ?aggregate=false) - validation predicted vs SA Bauland geomean (when aggregate runs) Example: /api/v1/land_prices/bezirk/305 (Amstetten, NÖ) Optional: ?year=YYYY, ?aggregate=false (skip per-parcel aggregation) GET /api/v1/land_prices/bundesland/{state_code} Bundesland (1-9 or 'AT') aggregate. Accepts state name too (e.g. /bundesland/Salzburg). Returns: - statistik_austria Bauland geomean across Gemeinden + Acker/Grünland buy + rent SA prices for the state - trends_cagr per-state CAGRs for Bauland, agri buy + rent - by_class per-class aggregates only when ?aggregate=true Per-class aggregation can be slow (millions of parcels) — opt in explicitly. Example: /api/v1/land_prices/bundesland/2 /api/v1/land_prices/bundesland/Salzburg?aggregate=true /api/v1/land_prices/bundesland/AT GET /api/v1/land_prices/predict Ad-hoc prediction for caller-supplied attributes (no DB lookup). Params: gemeinde_code, bezirk_code (optional, derived from gemeinde), state_code (optional, derived from gemeinde), area_sqm, building_count, footprint_count, compactness (0-1), landuse_codes (comma-separated NS codes), typology (urban|suburban|rural_settled|rural_agrarian), footprint_area_sqm (optional — enables building value estimate), building_period (optional: pre1960|y1960_1990|post1991), year. Example: /api/v1/land_prices/predict?gemeinde_code=61630&area_sqm=750 &building_count=1&compactness=0.6&typology=suburban &footprint_area_sqm=140&building_period=y1960_1990 GET /api/v1/land_prices/batch?parcel_ids=A,B,C&year=YYYY POST /api/v1/land_prices/batch Content-Type: application/json Body: {"year": 2024, "parcel_ids": [...]} (lookup mode) OR {"year": 2024, "inputs": [{...}, ...]} (ad-hoc mode) Max 1000 items per request. Returns per-item estimate plus a 'summary' block (combined parcel count, geomean buy, total value, total rent). Each 'inputs' entry accepts the same fields as /predict (including footprint_area_sqm, building_period). GET /api/v1/land_prices/validate Sample-based validation against Statistik Austria. Optional: limit (default 500, max 5000) random Bauland parcels per gemeinde, min_parcels_per_gemeinde (default 8), year. Returns: {summary {median_ratio, mean_ratio, pct_within_15pct, pct_within_25pct, pct_within_50pct, gemeinden_checked}, results [{gemeinde_code, n, sa_eur_per_sqm, predicted_eur_per_sqm, ratio_pred_over_sa}]} Building Value (per parcel) When the parcel has footprints linked via footprint_parcel_link, the service produces a separate 'building' block alongside land value: wohnflaeche_sqm = footprint_area_sqm × story_factor value_eur = wohnflaeche_sqm × Bezirk_Haus_EUR_per_sqm(year) story_factor by KG typology: urban 2.4 / suburban 1.7 / rural_settled 1.4 / rural_agrarian 1.2 Bezirk Häuserpreis source: Statistik Austria Häuserpreise.ods, 2015-2024 (Bezirk × Bauperiode × Wohnflächenkategorie × Größenkategorie), reduced to Bezirk × Bauperiode mean. When building_period is supplied the estimate uses the exact period (confidence=high); otherwise it uses the geometric mean across all periods (confidence=medium). Wien (district_code 900 in our index) is synthesised as the geomean of its 23 SA-Bezirke (910-923). A 'combined_value_eur' field is returned whenever both land and building values are present. Data files: data/statistik_austria/parsed/land_prices.json consolidated JSON data/statistik_austria/Baugrundstueckspreise.ods 2015-2024 data/statistik_austria/Haeuserpreise.ods 2015-2024 data/statistik_austria/SonderauswertungImmobilienDurchschnittspreise.ods data/statistik_austria/Bodenpreise*.csv, Pachtpreise*.csv (agri 2022-2024) ### LICENSE GET /api/v1/license Machine-readable licensing + recommended attribution for every data source (BEV CC BY 4.0 primary, OSM ODbL for osm{}, Statistik Austria, EEA, RIS, …). See "License & Attribution" below. ### DOCUMENTATION GET /api/v1/docs Interactive HTML documentation (this API's human-readable docs). GET /api/v1/docs/llm.txt This plain-text reference (optimized for LLM consumption). ?section=integration → returns ONLY the sibling-service integration spec (the contract for services that publish Austrian civic data to enrich the cadastre). Alias: ?integration=1. ## Integration Spec for Sibling Data Services (READ THIS IF YOU PUBLISH AUSTRIAN CIVIC DATA) This cadastre API is the spatial backbone of the Kohlschwarz think-tank (https://kohlschwarz.at:8000/llm.txt). We want to enrich every Austrian parcel with the public datasets that other sibling services already hold (forest loss, drought risk, farm subsidies, schools, childcare, maternity wards, wind/grid, INSPIRE, etc.). For that to work WITHOUT brittle re-geocoding on our side, each sibling service should expose its data pre-joined to the official Austrian administrative keys, at the FINEST granularity it can honestly support. ### The join keys (use the official ones, never invent your own) parcel_id "KGCODE-GNR" e.g. "63349-1314/1" (finest; one land parcel) kg_code 5-digit string e.g. "63349" (Katastralgemeinde) gemeinde_code 5-digit string e.g. "61630" (municipality / Gemeinde) ez Einlagezahl (int) within a kg_code (ownership folio) Rules: - kg_code and gemeinde_code are DIFFERENT numbering systems. Do not mix them. - PLZ (postal code, 4 digits) is NOT a key. Never expose data keyed only on PLZ. - If you only have point/address data, snap it to a parcel via POST /api/v1/spatial/points (exact point-in-polygon, returns parcel_id+kg_code) and store the resulting kg_code (and parcel_id when confident) alongside your record. - Always carry kg_code as the lowest-common-denominator key, even when you also publish parcel_id — it lets us aggregate and cross-link reliably. ### Resolve every name/code through ONE canonical lookup (so we all agree) Do NOT maintain your own table of Gemeinde/KG names, codes, or PLZ mappings, and do NOT fuzzy-match place names locally — that is exactly how two services end up with "Köflach"=61630 vs a typo'd variant and silently fail to join. Resolve through this API's EDM register lookup, which is the shared source of truth (BEV EDM / Statistik Austria official codes): GET /api/v1/lookup?q=&type=plz|gemeinde|kg|ortschaft&limit=20 Examples: /api/v1/lookup?q=8580 /api/v1/lookup?q=Kofla /api/v1/lookup?q=Wien&type=gemeinde Returns structured entries with the canonical {type, code, name, gemeinde_code, gemeinde_name, kg_code, plz[]} — use THOSE exact values. Workflow when ingesting your own raw data: 1. Take your place name / PLZ / code. 2. Hit /api/v1/lookup, take the single best match's gemeinde_code and (if a KG) kg_code. If the lookup is ambiguous or empty, flag the record — do not guess. 3. Store and publish those canonical codes. Now every sibling service keys on byte-identical strings and joins are exact. Related canonical resolvers on this API (use instead of rolling your own): - /api/v1/search/municipalities — 2114 Gemeinden (Statistik Austria codes) - /api/v1/lookup — PLZ ↔ Gemeinde ↔ KG ↔ Ortschaft (EDM register) - POST /api/v1/spatial/points — coordinate → parcel_id + kg_code (point-in-polygon) - /api/v1/landuse/codes — the canonical NS landuse code table (40–97) Treat the codes returned here as immutable identifiers: never relabel or zero-pad differently. gemeinde_code and kg_code are 5-character strings — keep leading zeros, keep them as strings (JSON), never cast to int. ### Everyone exposes the SAME per-KG endpoint (you do NOT need parcels) There is ONE required output for every sibling service: GET /llm/kg/{kg_code} → your data for that Katastralgemeinde You have never heard of a "parcel" and you don't need to. You only have to turn whatever you already hold into a per-KG answer. Two cases cover everyone: IF your data is per municipality (drought, subsidies, schools, childcare, ...): You already have a Gemeinde. A Gemeinde contains several KGs. So for a requested kg_code, look up its gemeinde_code (one call below) and return your Gemeinde-level numbers — every KG in that Gemeinde returns the same block. Set granularity="gemeinde". No geometry, no parcels. - Map kg_code → gemeinde_code with: GET /api/v1/lookup?q={kg_code}&type=kg (or fetch /api/v1/search/municipalities once and cache the mapping). IF your data is per coordinate (windmill, substation, station, facility, ...): You already have lon/lat. Snap each point to its KG ONCE by sending all your points to POST /api/v1/spatial/points — the response gives kg_code per point. Group your points by kg_code, then /llm/kg/{kg_code} returns the points (and any aggregate) for that KG. Set granularity="point". - Keep the raw lon/lat in each point so we can re-snap if boundaries change. That's the whole job: map UP (Gemeinde) or snap ONCE (coordinates), then answer per kg_code. The 'metrics'/'parcels' richness below is for the rare dataset that is already parcel-level; everyone else just fills 'metrics' (+ 'points' or 'history'). ### Historic / time-series data (uniform across all granularities) Whenever a metric changes over time, don't flatten it — attach a 'history' array so we can show trends. Same shape everywhere (per-Gemeinde, per-point, per-KG): "metrics": { "loss_ha": 12.4 }, // latest / headline value "history": [ { "as_of": "2020-12-31", "loss_ha": 9.1 }, { "as_of": "2021-12-31", "loss_ha": 10.3 }, { "as_of": "2024-12-31", "loss_ha": 12.4 } ] - 'as_of' is an ISO date (or year "2024"). Sort ascending. Same metric keys as 'metrics'. Missing year = omit the entry (don't pad with 0). ### The per-KG endpoint contract (required for all) Every sibling service exposes its data sliced by Katastralgemeinde: GET /llm/kg/{kg_code} → this service's data for one KG GET /llm/kg/{kg_code}.json (alias; JSON is the default + only required format) Response shape (stable, self-describing): { "service": "holzeinschlag-at", // short slug, matches your URL "dataset": "Forest loss & carbon emissions", "kg_code": "63349", "gemeinde_code": "61630", // echo back when known "granularity": "gemeinde" | "point" | "parcel", // what THIS payload really is "as_of": "2024-12-31", // data validity date (ISO 8601) "updated_at": "2026-06-01T10:00:00Z", // when you last recomputed it "source": "Global Forest Watch / Hansen v1.11", "license": "CC-BY-4.0", "unit_glossary": { "loss_ha": "hectares", "co2_t": "tonnes CO2e" }, "metrics": { ... }, // your headline numbers for this KG "history": [ ... ], // optional time series (see below) "points": [ // granularity="point": your snapped points in this KG { "id": "turbine_4711", "lon": 15.08, "lat": 47.06, "metrics": { ... } }, ... ], "parcels": [ // granularity="parcel" only (rare) { "parcel_id": "63349-1314/1", "metrics": { ... } }, ... ] } Contract notes: - Return HTTP 404 with {"kg_code":"...","error":"no_data"} when you hold nothing for that KG — do NOT 500 and do NOT return an empty 200 with no kg_code. - 'metrics' keys must be flat, snake_case, with units in the key or unit_glossary. Numbers as JSON numbers (never "1.234,5" strings). null means unknown, 0 means zero. - granularity="gemeinde": your numbers are municipal; every KG in a Gemeinde returns the same block. Just fill 'metrics' (+ 'history'). Omit points/parcels. - granularity="point": you snapped lon/lat to KGs once; return the points that fell in this KG under 'points'. Add a KG-level roll-up in 'metrics' if useful. - granularity="parcel": rare; you actually have per-parcel values — fill 'parcels'. - Whichever case: the entry point is ALWAYS /llm/kg/{kg_code}. One uniform door. ### Recommended companion endpoints (cheap to add, big payoff) GET /llm/manifest.json → which KGs you cover + schema, so we can crawl you: { "service": "holzeinschlag-at", "finest_granularity": "gemeinde", "join_keys": ["kg_code","gemeinde_code"], "kg_endpoint": "/llm/kg/{kg_code}", "metrics_schema": { "loss_ha": "number", "co2_t": "number" }, "kg_count": 1842, // how many KGs you can answer for "covered_kgs_url": "/llm/covered_kgs.json", // optional: array of kg_code "updated_at": "2026-06-01T10:00:00Z" } GET /llm/parcel/{parcel_id} → single-parcel slice, when granularity=parcel. GET /llm/kgs?codes=63349,63350,... → batch (≤500 codes) of the per-KG payload, returned as {"results":[ , ... ]}. Avoids N round-trips. ### Conventions all sibling services should follow - Base path is /llm/ ; advertise it from your top-level /llm.txt. - CORS: allow all origins (Access-Control-Allow-Origin: *). Read-only GETs. - Coordinates (if any) WGS84 lon/lat. Areas in m² or ha (state which in keys). - Be idempotent and cache-friendly: send Last-Modified / ETag; data is static-ish. - Version via the 'as_of' + 'updated_at' fields, not by changing key names. - Prefer additive schema changes; never silently repurpose an existing metric key. - Keep one stable 'service' slug forever — it becomes our cross-link namespace. In short: EVERY service exposes GET /llm/kg/{kg_code}. - Per municipality? Map kg_code→gemeinde_code (via /api/v1/lookup) and return your Gemeinde numbers; granularity="gemeinde". - Per coordinate? Snap your points once via POST /api/v1/spatial/points, group by kg_code, return them under 'points'; granularity="point". - Per KG/parcel? Return 'metrics' (+ optional 'parcels'); granularity="parcel". Resolve every code through /api/v1/lookup first so we all use identical keys, advertise it from /llm/manifest.json, attach 'history' for time series, and we fold your metrics into parcel/KG results here — no re-geocoding, no ambiguity. ## License & Attribution (you MUST carry this forward) Cadastral data: BEV – Bundesamt für Eich- und Vermessungswesen, Open Government Data, CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/). Copying, transformation and commercial use are permitted. Obligations when you display or redistribute anything from this API: 1. Attribution: "Datenquelle: BEV – Bundesamt für Eich- und Vermessungswesen" (+ year / Stichtag when known — see processing_metadata.processed_at or assembly_version.reprocessed_at on /api/v1/search/kg) 2. License link: https://creativecommons.org/licenses/by/4.0/ 3. Indicate changes: the data is MODIFIED (re-projected to WGS84, re-assembled from tile clips, simplified by z15/z16 quantisation, enriched) → say "bearbeitet" / "modified". 4. Do not imply BEV endorsement. Recommended one-liner (verbatim): Datenquelle: BEV – Bundesamt für Eich- und Vermessungswesen, CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/), bearbeitet Other sources mixed into responses: • OpenStreetMap — ODbL 1.0, NOT CC BY. "© OpenStreetMap contributors". Affects the osm{} block on parcel rows (/search/parcel, /search/ez, /query), /api/v1/osm/* and /search/address_osm. If you redistribute a DATABASE that merges osm{} fields, ODbL share-alike applies to that database — keep OSM fields in a separate file or license it ODbL. Zenodo snapshots and /export/gpkg|geojson contain NO OSM fields and stay CC BY 4.0. • Statistik Austria (municipalities, land prices) — CC BY 4.0, "Datenquelle: STATISTIK AUSTRIA". • EEA Natura 2000 — attribution to the European Environment Agency. • RIS (legal refs) — CC BY 4.0, "Datenquelle: RIS, Bundeskanzleramt". • WDPA, GADM (legacy endpoints) — non-commercial terms; prefer /natura2000 and /search/municipalities. Where it is disclosed: GET /api/v1/license machine-readable table of all sources + obligations meta.license on every standard JSON envelope, GeoJSON export, /osm/* (osm_note added when rows carry osm{}) Link: <…>; rel="license" + X-Data-Attribution headers on every /api/ response Personal data: owner (Eigentümer) data is not in the BEV open datasets and not served here. EZ numbers are public cadastre attributes (shown on kataster.bev.gv.at). ## Data Model ### Parcel (Grundstück) The fundamental unit of Austrian land registration. Fields: parcel_id ("KG-GNR", e.g. "75414-1314/1"), kg_code, gemeinde_code, gnr, ez (Einlagezahl/folio), status (E or G), area_sqm, lon, lat (centroid WGS84), building_count, footprint_count, total_building_area_sqm, landuse_codes (comma-separated NS codes), landuse_summary (JSON object of code→count) V2 metrics: compactness (isoperimetric quotient, 1.0=circle, 0.78=square), elongation (bbox aspect ratio, 1.0=square, >4=strip), vertex_count, far (floor area ratio: building_area/parcel_area), shape_class (regular|moderate|irregular|complex|strip), dominant_landuse (most frequent landuse type on parcel) Overlay (on-the-fly, no index rebuild): in_natura2000 (bool, when centroid inside any Natura 2000 site), natura2000_sites [] of {sitecode, sitename, sitetype (A|B|C), site_type_label, area_ha} legal_refs [] (when RIS legal references exist) osm {dist_road_m, dist_major_road_m, dist_transit_m, dist_train_station_m, dist_rail_m, dist_water_m, dist_settlement_m, remoteness 0..100, road_on_parcel, + fclass/name/ref of each nearest feature} (when OSM proximity metrics are cached — see OSM PROXIMITY section) ### Building (Gebäudeadresse) Building addresses registered in the cadastre. Fields: building_id, kg_code, gemeinde_code, house_number, street, postal_code, ort (locality), full_address, object_type, lon, lat (WGS84) Spatial/bbox queries also return: _layer ("building"), parcel_id, ez (from parcel_buildings join) ### Katastralgemeinde (KG) Cadastral municipality — the organizational unit of the Austrian cadastre. Fields: kg_code (5 digits), kg_name, gemeinde_code, gemeinde_name, district_code, district_name, state_code, state_name, min_lon/min_lat/max_lon/max_lat (bounding box), parcel_count, building_count, footprint_count, landuse_count, total_area_sqm V2 metrics: typology (urban|suburban|rural_settled|rural_agrarian), building_density (per km²), built_up_ratio (footprint area / total area), avg_nn_distance_m (mean nearest-neighbor distance between buildings), dominant_landuse (most frequent NS code), landuse_diversity (Shannon entropy), avg_far (mean floor area ratio), avg_compactness (mean parcel compactness), ez_count, avg_parcels_per_ez (ownership fragmentation), total_footprint_area_sqm, footprint_median_area_sqm, median_parcel_area_sqm Overlay (on-the-fly): natura2000_overlap_count (sites whose bbox intersects this KG — superset), natura2000_inside_count (sites with at least one parcel-centroid inside this KG — exact), natura2000_inside_sites [] each with parcel_count + contains_parcels, natura2000_near_only_count (bbox-near with no parcels inside), natura2000_parcel_count (exact parcels in this KG inside any site), natura2000_site_codes [], natura2000_site_names [], legal_ref_count, legal_contexts [] ### Einlagezahl (EZ) Land register folio grouping parcels under one ownership entry. Fields: kg_code, ez, parcel_count, total_area_sqm, building_count, landuse_codes, landuse_summary, bbox ({min_lon, min_lat, max_lon, max_lat, center_lon, center_lat}), buildings (array of building addresses on the EZ, in detail view), landuse_area_breakdown (landuse code → area_sqm from polygon geometry, in detail view) ### Building Footprint (Gebäudegrundriss) — V2 Building footprint shape metrics, one row per footprint in the footprint_metrics table. Fields: footprint_id, kg_code, area_sqm (from geometry), perimeter_m, compactness (isoperimetric quotient), elongation (bbox aspect ratio), vertex_count, size_class (tiny <30m² | small 30-100 | medium 100-300 | large 300-1000 | xlarge >1000), shape_class (rectangular | regular | lshaped | complex), ns_code (BEV Nutzungssymbol code from footprint properties) ### Legal Parcel Reference (RIS) A mapping from a cadastre parcel to an Austrian law that references it. Fields: kg_code, kg_name, grundstueck (GNR), source_law_name, source_date, source_url (link to RIS consolidated text), parcel_list_type (listed|boundary_walk), legal_context (national_park|nature_protection|landscape_protection|nature_park| water_protection|species_protection|admin_boundary|zoning|monument_protection| hunting|forestry|other), bundesland, gesetzesnummer Source: data/legal_parcel_refs.csv (13,800+ rows, 818 KGs, 105 laws) Loaded at startup into in-memory indices by KG, parcel, and EZ. ### Landuse (Nutzungssymbol) Land use classification per parcel (codes 40-97). Abbreviations: B=Baufläche, V=Verkehr, W=Wald, LN=Landwirtschaft, A=Acker, GW=Gewässer, WG=Weingarten, GA=Gartenanlage, OG=Obstgarten, Alpe=Alpine pasture, Öd=Ödland, Fe=Fels, Gl=Gletscher, Su=Sumpf Landuse Data Sources (important for export): • NFL layer (Nutzflächen) — Polygon geometries for larger landuse areas Source: source="nfl", ~42% of features, Polygon/MultiPolygon geometry • NSY layer (Nutzungssymbole) — Point symbols for smaller landuse features Source: source="nsy", ~58% of features, Point geometry • For ML training: use layers=landuse_polygons to get only polygon features with computed area_sqm • For geocoding: use layers=landuse_points to get only point centroids • For full dataset: use layers=landuse to get mixed Point/Polygon features (backwards compatible) ### Protected Area (WDPA) 43 Austrian protected areas from World Database on Protected Areas. Fields: name, desig, desig_eng, desig_type, iucn_cat, rep_area, gis_area, realm, geometry (polygon) ### Statistik Austria Municipalities (preferred) 2114 Austrian municipalities from Statistik Austria (official, correct names). Fields: gemeinde_code (5-digit Gemeindekennziffer), name, district_code, district_name, state, lon, lat, geometry (polygon) Access: /api/v1/search/municipalities ### GADM Municipality (legacy) 2100 Austrian municipalities from GADM v4.1. ~587 names have missing spaces. Fields: gid_3, name_3 (municipality), name_2 (district), name_1 (state), type_3, geometry (polygon) Access: /api/v1/search/gadm ## Administrative Hierarchy Austria → 9 Bundesländer (states) → ~80 Bezirke (districts) → ~2100 Gemeinden → ~8000 Katastralgemeinden (KG) → parcels + buildings KG codes: 5 digits (e.g. 63349 = Köflach, 75414 = Kohlschwarz) PLZ: 4 digits (postal code, maps to KGs via plz_kg table) ## Response Formats Every data endpoint supports ?format= with these values: | Format | Content-Type | Description | |----------|----------------------------------|--------------------------------------------------------------------| | json | application/json | Default. Wrapper: {data:[…], meta:{total,limit,offset,format,query_time_ms}} | | geojson | application/geo+json | GeoJSON FeatureCollection with full geometry per feature | | csv | text/csv | RFC 4180, BOM, Content-Disposition attachment. Geometry as WKT | | gpkg | application/geopackage+sqlite3 | Single-layer GeoPackage via ogr2ogr. Content-Disposition attachment | GeoPackage details: - ?format=gpkg on any spatial/search endpoint → single-layer GPKG download - /api/v1/export/gpkg → multi-layer GPKG with all cadastre layers: parcels, buildings, building_footprints, labels, landuse_polygons, landuse_points - Cross-boundary features are deduplicated in multi-layer export - Requires ogr2ogr (gdal-bin) on server (installed) - KG data is downloaded from Zenodo on-demand if not locally cached ## URL Encoding **All query parameters MUST be properly URL-encoded.** This is critical for Austrian place names. - Spaces: "Fladnitz an der Teichalm" → Fladnitz%20an%20der%20Teichalm - Umlauts: "Premstätten" → Premst%C3%A4tten, "Köflach" → K%C3%B6flach - Slashes in GNR: "1314/1" → 1314%2F1 - Sharp S: "Straßengel" → Stra%C3%9Fengel All responses are UTF-8 JSON. The API handles URL-decoded UTF-8 input correctly. ## Response Contract - Every data endpoint returns: {"data": [...], "meta": {"total": N, ...}} - "data" is ALWAYS an array (never null), even when no results match. - "meta.total" gives the total matching count (before limit/offset). - Error responses: {"error": "message", "status": 400|404|500|503} - If the search index is not yet loaded: HTTP 503 with "search index not initialized". ## Identifier Types — Know the Difference | Type | Format | Example | Where to use | |------|--------|---------|--------------| | KG code | 5 digits | 63349 | kg= parameter in /search/ez, /query, /landuse/distribution, etc. | | Gemeinde code | 5 digits | 61630 | gemeinde= parameter in /query, /search/ez, /search/kg | | PLZ | 4 digits | 8580 | plz= parameter in /query, /search/kg, /search/address | | Parcel ID | KG-GNR | 63349-505/3 | id= in /search/feature, /search/parcel | | GNR | varies | 505/3, .1, 47 | gnr= in /search/gnr | | EZ | integer | 261 | ez= in /search/ez | **KG codes and Gemeinde codes are both 5 digits but are NOT interchangeable.** A KG code identifies a Katastralgemeinde; a Gemeinde code identifies a municipality. One Gemeinde contains 1-20+ KGs. Use /api/v1/lookup to resolve between them. ### Ingest provenance: which pipeline generation produced this KG's data Austria's ~7 850 KGs were NOT all processed by the same code. 7 848 of them come from one bulk run in March 2026; since then several commits changed what a processing run WRITES, and a KG only carries those fixes if it has been reprocessed since. /api/v1/search/kg rows therefore carry: assembly_version tile_partition_v1 | legacy | unknown ingest_generation newest ingest epoch the stored data satisfies reprocessed_at RFC3339 processing date the stamps derive from ingest_fixes_missing[] ingest fixes this KG's data predates (omitted if none) The epoch table (id — commit — effective — what changed). "sb@" commits are in the predecessor repo github.com/raffopenssh/strassen-blockade-at, where the pipeline that produced the country's data ran before this repo existed: pipeline_v6_union sb@b80b17a 2026-03-13T19:29Z pre-Zenodo pipeline generations v2-v6 (z16 HD footprints, parcel union, z16 tile-edge stitching) bulk_v7_zenodo sb@71f27e9 2026-03-14T18:12Z the country-wide v7 run (processing_version inspire-v2), 2026-03-15 to 03-25 — what 7 848 of 7 850 KGs still carry landuse_area_calc 427a916 2026-04-08T15:05Z landuse polygon areas computed at processing time (feedback #6) multipolygon_geometry 484de69 2026-08-04T16:39Z MultiPolygon parcels no longer truncated to the first ring of the first part (feedback #8) address_point_join c4a2573 2026-08-05T07:06Z address points joined to the parcel that contains them (feedback #9) ns_code_table 9078a35 2026-08-06T06:17Z corrected BEV NS code table landuse_tile_clip eb3e632 2026-08-06T07:04Z landuse tile-clip area loss (feedback #11) ring_order d3fb479 2026-08-06T10:23Z polygon ring order normalised (feedback #13) tile_partition_v1 9bf0178 2026-08-06T10:46Z tile-partitioned parcel assembly, closes the coverage holes of #14 Two of these are ALSO applied on serve, so they are corrected for every KG regardless of its stamp: ring_order (normalised on decode/export) and multipolygon_geometry (the geometry caches re-derive it). landuse_tile_clip is corrected out-of-band by the landuse_truth overlay, which reports landuse_areas_source per row. The rest — tile_partition_v1, address_point_join, ns_code_table — genuinely require a reprocess, which is why the stamp exists. bulk_v7_zenodo is where practically the whole country sits: every KG file on disk reports processing_version "inspire-v2" with processed_at between 2026-03-15 and 2026-03-25, matching the Zenodo upload distribution exactly. pipeline_v6_union matches no KG today and is expected not to: for all 863 KG files checkable locally, the gap between processed_at and the Zenodo upload has median 3.5 s (max 2.6 h), so every stored file was freshly processed by the v7 run rather than being an older v2-v6 file that was only uploaded by it. The row exists as a floor so a genuinely older file would bucket there rather than be mislabelled. There is deliberately no epoch for this repo's first commit (the processing/API split): it changed no ingest output, and adding it would have split the single March run into two buckets by clock alone. An effective date is the DEPLOY of the fix, not always the commit timestamp: KG 84108 was reprocessed at 10:56 with a binary built at 10:46 that already contained the tile-partition fix, four minutes before it was committed. Where they differ we take the binary, because that is what wrote the bytes. /api/v1/export/geojson → meta.assembly_versions (per-KG stamp for a single KG, counts per version otherwise) /api/v1/kg_versions → ingest_epochs catalogue + ingest_generations census /api/v1/kg_versions?kg=X → that KG's stamp, its ingest_fixes / _missing, and its Zenodo snapshot history. KGs uploaded before dated snapshots existed still get one baseline entry (source="zenodo_manifest_alias") reconstructed from the manifest's upload date, so no KG looks version-less. The stamps need no reprocess to be correct and update within 10 minutes of a KG being reprocessed by any process (immediately for a reprocess in-process). ### KG code format (zero padding) A KG code is FIVE digits. 740 of the 7,850 Austrian KG codes begin with a zero (01503 Heiligenstadt, 07201 Aalfang), and every place this API STORES one uses the padded form: kg.kg_code in the index, {kg}.json.gz filenames, parcel_id prefixes (01503-601/1), footprint_id prefixes (01503_fp_9). On INPUT both spellings work. ?kg=1503 and ?kg=01503 are equivalent, as are /api/v1/parcels/1503-601%2F1/geometry and .../01503-601%2F1/geometry — a 4-digit numeric KG code is zero-padded centrally before any handler sees it, on /api/v1/*, on the legacy /api/search/*, in POST batch bodies, and in path-embedded ids. When a request used the short form the response carries the advisory header: X-KG-Code-Normalized: true On OUTPUT everything (including /api/v1/lookup, which used to be the odd one out) emits the padded form, so a code taken from one response can always be fed straight into the next request. What is NOT padded, deliberately: - a 1-3 digit value, because it is a typeahead PREFIX, not a code (/api/v1/lookup?q=63 must keep matching "63xxx", not become "00063") - plz= — a PLZ is also 4 digits (8580) and belongs to a different namespace - gemeinde= / gemeinde_code= / district= / ez= / gnr= — separate namespaces - ?id= on /api/v1/search/municipalities (a Gemeindekennziffer); ?id= is only rewritten when it looks composite ("1503-601/1") ### Polygon geometry contract (RFC 7946) — fixed Aug 2026 Every endpoint that returns a parcel, footprint or landuse polygon now emits rings in RFC 7946 order, and ALL of them agree ring-for-ring: - coordinates[0] of each polygon part is the EXTERIOR ring, wound CCW - every following ring of that part is an interior HOLE, wound CW - a ring bag describing several DISJOINT shells comes back as a MultiPolygon (with geometry_parts), never as a shell plus impossible "holes" - /export/geojson, /export/gpkg, /spatial/parcels, /parcels/{id}/geometry and /parcels/geometry/batch are byte-identical for the same parcel, so results can be cached and merged across endpoints What it looked like before, and why you may need to re-fetch: the BEV tile source hands out an UNORDERED ring bag, and the json.gz-backed paths served it verbatim. In KG 84108 (Nauders I), 81 of 85 multi-ring parts had a coordinates[0] that was not the exterior — worst case 84108-3333/1, whose ring[0] is a 37 m² sliver in front of the real 2.93 km² outline. A spec- following consumer therefore rendered and hit-tested the sliver. Two further causes of self-inconsistent output are fixed with it: a hole whose first vertex sits exactly ON its parent's boundary (extremely common — cadastral neighbours share vertices) was promoted to a second shell, and a hole emitted as its own sibling MultiPolygon part was never recognised as nested. Both produced "Nested shells" under GEOS/shapely and double-counted the overlapping area. Measured on KG 84108 (4,346 parcels): polygons shapely rejects 64 → 16, and Σ(served geometry)/Σ(area_sqm) 0.70 → 1.011, i.e. right at the expected cadastral-vs-geodesic ratio. You no longer need the even-odd / largest-ring workaround, and make_valid() is no longer required for ring ORDER. The 16 residual invalid polygons in that KG are self-intersecting rings in the BEV source itself (a different defect, not repaired server-side); if you need strict validity, keep a make_valid() call as a cheap safety net. ### Landuse labels changed (Aug 2026) — BREAKING for string matching The German landuse labels this API returns were WRONG and have been corrected against the primary source: BEV Schnittstellenbeschreibung "Katastralmappe SHP" V2.9, Tabelle 8 "Nutzungssymbole (NS)" (pages 13-15). 22 of the old codes carried invented names. The NS CODES never changed — only the text. If you match on the label string, you must update. If you match on landuse_code / the code field, nothing changes. The two that matter most: code old label (wrong) new label (BEV spec) 48 "Verkehrsfläche - V" "Äcker, Wiesen oder Weiden - LN" 83 "Fels/Geröll - Fe" "Gebäudenebenflächen - B(nf)" Code 48 is the single most common code in Austria (3.76M parcels) — it is farmland, and was being reported and PRICED as road surface. Code 83 is a Baufläche, not rock. Also corrected: 52 Gärten, 53 Weingärten, 54 Alpen, 55 Krummholzflächen, 57 Verbuschte Flächen, 58 Forststraßen, 59 Fließende Gewässer, 60 Stehende Gewässer, 61 Feuchtgebiete, 62 Vegetationsarme Flächen, 63 Betriebsflächen, 64 Gewässerrandflächen, 65 Verkehrsrandflächen, 72 Friedhöfe, 84 Abbauflächen/Halden/Deponien, 87 Fels- und Geröllflächen, 88 Gletscher, 92 Schienenverkehrsanlagen, 95 Straßenverkehrsanlagen, 96 Freizeitflächen. Codes BEV does not define (the old table had 49, 50, 51, 66, 67, 70, 71, 73-82, 85, 86, 89-91, 93, 94, 97) were removed rather than guessed. They do not occur in the data; if one ever appears it now reports "Unbekannt - Code NN" instead of a plausible-looking invention. The full current table is at GET /api/v1/landuse/codes. Affected fields: landuse_summary (object KEYS), dominant_landuse, landuse_breakdown[].type / .type_name / .abbr, landuse_type, landuse_abbr, and the tokens matched by full-text search on landuse. Rollout: labels are corrected AT READ TIME, so every response is already right regardless of when the underlying KG was indexed. The stored index rows are being rewritten separately (a background pass); until it finishes, direct SQL against a copy of the index may still show old strings while the API does not. Related, same release: class in /api/v1/land_prices/* is now derived from the AREA split of the parcel (exact clipping of the nfl landuse polygons) rather than from symbol presence, and reports class_source ("area" | "symbol"), class_share, landuse_areas[] and buy_total_blended_eur. Symbol presence is not area dominance: a 17.9 ha field carrying three stray building/road glyphs was classified bauland_built and valued at 3.42m EUR instead of ~0.74m. ## Validating Inputs with /api/v1/lookup /api/v1/lookup is the master lookup that does NOT require the search index. Use it FIRST to validate names and resolve codes before making search/query calls. Workflow for a Gemeinde name: 1. GET /api/v1/lookup?q=Frohnleiten&type=gemeinde → Returns: [{"code": "60663", "name": "Frohnleiten", "gemeinde_code": "60663"}] 2. GET /api/v1/lookup?q=60663&type=kg → Returns: [{"code": "63013", "name": "Laas", "gemeinde_code": "60663"}, ...] (all KGs in the Gemeinde) 3. Now use the kg_code or gemeinde_code in search/query endpoints. Workflow for a place name (fuzzy, handles missing diacritics): 1. GET /api/v1/lookup?q=Kofla → finds "Köflach" (diacritics-insensitive) 2. GET /api/v1/lookup?q=Premstatten → finds "Premstätten" 3. Use the returned code values for further queries. Workflow for a PLZ: 1. GET /api/v1/lookup?q=8580 → returns matching PLZ entries with associated Gemeinde/KG info 2. GET /api/v1/search/kg?plz=8580 → returns all KGs mapped to that postal code ## Recommended Query Patterns ### "Find all parcels in a municipality" GET /api/v1/query?gemeinde=61630 (numeric code) or GET /api/v1/query?gemeinde=K%C3%B6flach (URL-encoded name) ### "Get all KGs for a Gemeinde" GET /api/v1/search/kg?gemeinde=61630 (numeric) or GET /api/v1/search/kg?gemeinde=K%C3%B6flach (name, URL-encoded) ### "Look up a specific EZ with full details" GET /api/v1/search/ez?kg=63349&ez=261 → Returns: summary (bbox, building_count), parcels[], buildings[], landuse_area_breakdown[] ### "Search across all EZs in a municipality" GET /api/v1/search/ez?gemeinde=61630&limit=1000 → Returns EZs from ALL KGs in the Gemeinde (limit default is 100) ### "Which KGs are in this bounding box?" GET /api/v1/spatial/kgs?west=15.0&south=47.0&east=15.1&north=47.1 → Returns kg_codes array + full KG details. Lightweight, no parcel/building data. ### "Which KGs overlap this polygon?" POST /api/v1/spatial/kgs with GeoJSON Polygon body → Uses proper bbox-polygon overlap (vertex, corner, and edge intersection tests) ### "Find features near a coordinate" GET /api/v1/spatial/point?lon=15.05&lat=47.05&radius=500&layers=parcels,buildings ### "Batch query many points at once" POST /api/v1/spatial/point Body: {"points": [{"lon": 15.1, "lat": 47.2, "id": "site1"}, ...], "radius": 200, "layers": ["parcels"]} ### "What municipality is at this coordinate?" GET /api/v1/search/municipalities?contains_lon=15.15&contains_lat=47.17 (no search index needed) GET /api/v1/search/gadm?contains_lon=15.15&contains_lat=47.17 (legacy, some names broken) ### "Is this point in a protected area?" GET /api/v1/search/protected_area?contains_lon=16.7&contains_lat=48.1 (no search index needed) ### "Which laws reference parcels in this KG?" GET /api/v1/legal/kg/49006 → Returns all legal refs with distinct legal_contexts (e.g. national_park) ### "Is this parcel referenced in any law?" GET /api/v1/legal/parcel/49006/213/1 → Or search parcels; legal_refs array is automatically included GET /api/v1/search/parcel?id=49006-213/1 (includes legal_refs in response) ### "Find all parcels inside a national park (spatial)" GET /api/v1/query/protected_area?area=Kalkalpen&relation=within → Uses actual WDPA polygon geometry. Includes parcels NOT named in law. → legal_status tells you: "both", "spatially_contained", or "legally_named" ### "Find parcels near a national park boundary" GET /api/v1/query/protected_area?area=Donau-Auen&relation=near&buffer_m=1000 → Returns parcels within 1km of the park boundary, sorted by distance ### "Find all legally named national park parcels" GET /api/v1/legal/search?context=national_park (1257 parcels across Austria) ### "Which KGs have water protection designations?" GET /api/v1/legal/search?context=water_protection&limit=1000 → Returns parcels from KGs with Grundwasserschongebiet designations ### V2: "Find all urban KGs" (direct SQL via the index) The v_kg_profile view exposes KG typology and density metrics. Fields: typology (urban|suburban|rural_settled|rural_agrarian), building_density, built_up_ratio, avg_nn_distance_m, landuse_diversity, avg_far, avg_compactness, ez_count, avg_parcels_per_ez, footprint_median_area_sqm, median_parcel_area_sqm The /api/v1/stats endpoint returns typology distribution counts. ### V2: "Classify parcel shapes" The v_parcel_shape view exposes compactness, elongation, vertex_count, far, shape_class. shape_class values: regular (compact plots), moderate (typical residential), irregular (complex boundary), complex (very irregular), strip (roads/rivers, elongation >4) ### V2: "Analyze building footprints" The footprint_metrics table has per-footprint shape analysis. size_class: tiny (<30m², sheds), small (30-100, houses), medium (100-300), large (300-1000, apartments/schools), xlarge (>1000, industrial/commercial) shape_class: rectangular (≯5 verts, compact), regular, lshaped (L/T/U shapes), complex (>12 verts) ### "How is this building oriented / how big is it really?" GET /api/v1/footprints/01002_fp_0/geometry → True oriented length×width (obb_length_m/width_m), the long-axis compass bearing (orientation_deg 0–180, orientation_axis), the bearings the façades face (long_side_faces_deg / short_side_faces_deg), plus the polygon. The axis-aligned elongation in footprint_metrics misreads diagonal buildings; this OBB is the real shape. Warms the KG on first access. GET /api/v1/spatial/footprints?lon=&lat= → same metrics for buildings at a point GET /api/v1/parcels/{id}/footprints?shape=1 → enrich a parcel's footprint list ## Common Mistakes 1. NOT URL-encoding umlauts/spaces in query parameters — causes HTTP errors 2. Using a Gemeinde code where a KG code is expected (or vice versa) 3. Assuming data is non-null — always handle empty arrays defensively 4. Using PLZ as a KG code — PLZ are 4 digits, KG codes are 5 digits 4b. Dropping the leading zero of a KG code. 740 KG codes start with 0 (01503, 07201) and the stored form is always padded. The API now zero-pads a 4-digit ?kg= / parcel_id / footprint_id for you (and sets X-KG-Code-Normalized: true), but store the padded form: string-comparing "1503" against a returned "01503" — or using it as a dict key — will still not match. 5. Not setting limit= high enough — default is 100, one Gemeinde can have 1000+ EZs 6. Using gemeinde name without checking exact spelling — use /api/v1/lookup first 7. Forgetting that /api/v1/lookup, /search/municipalities, /search/gadm, and /search/protected_area work WITHOUT the search index ## Tips 1. Start with /api/v1/lookup to validate any name or code before querying 2. Use /api/v1/search/text?q=... for broad discovery across all entity types 3. Use /api/v1/query for complex filtered analysis with built-in statistics 4. Chain: lookup Gemeinde → find KGs → get parcels → get EZ details → get buildings 5. The landuse filter accepts both numeric codes (56) and abbreviations (W for Wald) 6. Parcel IDs follow the pattern "KGCODE-GNR" e.g. "75414-1314/1" 7. All coordinates are WGS84 (EPSG:4326), lon/lat order 8. Austria bbox: roughly lon 9.5-17.2, lat 46.3-49.0 9. Use POST /api/v1/spatial/point for batch queries (up to 1000 points at once) 10. gemeinde parameter accepts numeric Gemeinde codes (e.g. 61630) in /query, /search/ez, /search/kg 11. POST /api/v1/feedback to report bugs or request features — agents should self-report issues 12. GET /api/v1/export/geojson?kg=...&layers=parcels for full polygon geometry as JSON (no GPKG needed) 13. POST /api/v1/spatial/polygon with a GeoJSON FeatureCollection for batch polygon intersection (up to 500) 14. V2 index adds typology, shape metrics, and FAR — check /api/v1/stats for urban/suburban/rural KG counts 15. Parcel shape_class='strip' identifies road and river corridors; far>0.5 identifies densely built parcels 16. KG landuse_diversity (Shannon entropy) distinguishes mono-culture (forest, farmland) from mixed-use areas 17. /api/v1/legal/search?q=... finds parcels referenced in Austrian laws — useful for nature protection, zoning, and boundary analysis 18. Parcel, KG, and EZ search results are automatically enriched with legal_refs / legal_ref_count when legal references exist 19. Legal ref legal_context values: national_park, nature_protection, landscape_protection, viticulture, water_protection, admin_boundary, zoning, monument_protection, etc. 20. /api/v1/query/protected_area spatially queries parcels inside WDPA park polygons — legal_status distinguishes "legally_named" (in RIS law text) from "spatially_contained" (inside polygon but not named in law)