# Taxon Partner API 2.0: integration guide (EN)

Version 2.5.1, 16-09-2026 (English contract; see section 14 for the mapping from the 1.x Dutch names; the same table is section 12 of the HTML documentation). Full documentation: https://taxonapi.be/partner-docs/ (EN), /partner-docs/index-fr (FR), /partner-docs/index-nl (NL); machine-readable: /partner-docs/openapi.yaml. This guide in French: guide-fr.md; in Dutch: gids-nl.md.
For the technical staff of the partner. No prices in this document; they are in the agreement. The agreement, the prices and your keys are confidential; this documentation itself is published openly.

## 1. Quickstart in five lines

1. Base URL `https://taxonapi.be/api/v1/partner/`, HTTPS only, server to server.
2. Every request: `X-Api-Key` (tx_live_ or tx_test_) and `X-User-Ref` (required, your user id). Recommended: `Idempotency-Key` (UUID per request), `Accept-Language: en` (or `fr`, `nl`). `X-Case-Ref` (your case number) is technically optional (a request without it is accepted; `billing.case_ref` is then `null` and the CSV column empty) but contractually required: every request belongs to a valuation case, so always send it.
3. `GET /address?address=...&type=house|apartment|land&sections=basic[,avm][&living_area_m2=&year_built=&epc=&condition=&bedrooms=&plot_area_m2=&capakeys=]`. `sections=basic` (default) is the basic package: `basic` + `parcel` + `price_map` together, one price per case; `avm` is a separate option (requesting `parcel` or `price_map` separately is normalised to the package). `living_area_m2` is required for `avm` (not for `type=land`: no AVM for land, see the subsection Land in section 2). When a property consists of several parcels (garden, garage, meadow), pass them as `capakeys` (at most 10): first request without to get the candidates, then with (section 3).
4. Same user + same property within 30 days = free (`from_cache: true` or notices `dedup` per section); the AVM option added later = only that option; another user = a new charge. Same `Idempotency-Key` within 24 h = same response (`X-Idempotent-Replay: true`), not counted. AVM without `living_area_m2` is not an error: HTTP 200 with notice `living_area_required`, section skipped.
5. Always show `attribution` and `disclaimer` from the response, literally and visibly; keep raw responses for at most 30 days; photos: fetch them within the validity of the link and embed them only in the report of the case, with a visible source line (section 5).

**User.** A user is the unit you pass yourself with every request: an office, an employee, a branch or a case handler. That choice determines billing: the same user requesting the same property again within 30 days does not pay again (dedup); another user does. A daily cap applies per user, a global cap per key. Caps: 20 requests per user per day, 300 per key per day, 60 per minute with a burst of 20 (test key: 20 per day). What counts toward the daily caps (and as a request in `/usage`): every request that reaches the address lookup, so 200 (fresh, dedup or partial), 404 `address_not_found`, 422 `address_imprecise`, `capakey_too_far` and `type_unsupported`, 502 and 504; not counted: 400, 401, 403, 405, 409, 422 `capakey_invalid`, 429, 503 and Idempotency-Key replays.

**Case.** `X-Case-Ref` is your case number (`A-Z a-z 0-9 . _ : @ / space -`, at most 64). It comes back in `billing.case_ref` and in the usage CSV, so that every request can be tied to a case in your bookkeeping. Every request belongs to a concrete valuation case (contractual rule).

## 2. curl

```bash
# Health (no key)
curl -s https://taxonapi.be/api/v1/partner/health

# basic package + AVM option, Walloon address, English labels
curl -s -G "https://taxonapi.be/api/v1/partner/address" \
  -H "X-Api-Key: $TAXON_API_KEY" \
  -H "X-User-Ref: ag-0417" \
  -H "X-Case-Ref: PT-2026-004512" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Accept-Language: en" \
  --data-urlencode "address=Avenue Alphonse Allard 94, 1420 Braine-l'Alleud" \
  --data-urlencode "sections=basic,avm" \
  --data-urlencode "type=house" \
  --data-urlencode "living_area_m2=165" \
  --data-urlencode "year_built=1978" \
  --data-urlencode "epc=D" \
  --data-urlencode "condition=good" \
  -D - | sed -n '1,20p;/^{/,$p'

# Usage of a month as CSV (Excel-ready, ; and decimal comma)
curl -s "https://taxonapi.be/api/v1/partner/usage?month=2026-09&format=csv" \
  -H "X-Api-Key: $TAXON_API_KEY" -o taxon-usage-2026-09.csv
```

Never put the key on the command line in shared environments; use an environment variable or a secret store.

What the first call returns (live, 07-09-2026, test key, shortened): `sections_delivered: ["basic", "parcel", "avm", "price_map"]`, `address.region: "WAL"`, `address.nis_code: "25014"`, `parcel.capakey: "25744E0226/00_000"`, `parcel.zoning.plan: "plan de secteur"`, `avm.value` with `range`, `range_90`, `rental_value_range` and `confidence.label: "medium"`, `avm.price_basis: "asking_price_model"`, notice `avm_indicative_not_regionally_calibrated` (Wallonia), `attribution: "References: Taxon (taxon.be)"`. The examples use `uuidgen` and `jq` (Linux/macOS); in Windows PowerShell use `[guid]::NewGuid()` for the key and leave out the `| jq` filter (with an empty value curl sends no `Idempotency-Key` header at all).

**Usage per API key (2.4.1).** `GET /usage` also carries the block `per_key` (between `per_user` and `per_day`): the usage per API key, with the same filters as the rest of the response (month, the environment of the calling key, `user_ref`). Per key: `prefix` (the first 12 characters, as in your key list), `label` (`null` when empty), `environment`, `active`, `revoked_at` (`null` while active), `requests`, `cases`, `charged`, `sections`, `sections_delivered`, `amount` and `last_activity`; sorted by `amount`, then `requests`, descending. Keys without requests in the month are not listed; a revoked key with requests in the month is (`active: false`, `revoked_at` filled in). The sums over `per_key` equal `total`. The CSV (`format=csv`) gets `key_prefix` as its 16th and last column. Example with a current live key and a previous live key that was revoked since:

```json
"per_key": [
  {
    "prefix": "tx_live_3f9a",
    "label": "production",
    "environment": "live",
    "active": true,
    "revoked_at": null,
    "requests": 3,
    "cases": 3,
    "charged": 3,
    "sections": {"package": 2, "avm": 2},
    "sections_delivered": {"basic": 3, "parcel": 3, "avm": 2, "price_map": 3},
    "amount": 10.0,
    "last_activity": "2026-09-08T10:39:27+02:00"
  },
  {
    "prefix": "tx_live_b71c",
    "label": "old key",
    "environment": "live",
    "active": false,
    "revoked_at": "2026-09-08T10:39:20+02:00",
    "requests": 1,
    "cases": 1,
    "charged": 1,
    "sections": {"package": 1, "avm": 0},
    "sections_delivered": {"basic": 1, "parcel": 1, "avm": 0, "price_map": 1},
    "amount": 3.0,
    "last_activity": "2026-09-08T10:39:15+02:00"
  }
]
```

### Land (`type=land`, 2.5.0)

`type=land` (aliases `grond`, `terrain`, `bouwgrond`) requests the bundle for a building plot. Compared to a house or apartment: one comparables block (`type: land`, `transaction: sale`, no rent block) with land listings for sale (building plots, project land and land without a more precise subtype; agricultural land, meadows, woodland, orchards, industrial and business land, parking spaces, garages and recreational land are excluded on the basis of the advertised subtype) of all publication years (2.5.1: no age limit, so that you can adjust the asking prices of older listings for the market evolution within the case (price indexation); the licence terms remain: no merging, indexing or storing across cases), within 5 km of the address (extended once to 10 km with fewer than 10 in total; still fewer than 10 = block field `low_sample: true` and notice `low_sample`), ranked with the listings of the last 24 months first (sorted by distance) and the older ones after them (sorted by distance), at most 25 per block; every item carries `age_months` (whole calendar months between `published` and today, 0 for this month) and the block field `max_age_months` is `null` for land; every item carries `plot_area_m2` and `price_per_m2_plot` (asking price per m² of plot), the housing fields (`living_area_m2`, `price_per_m2`, `bedrooms`, `epc_label`, `year_built`, `condition`, `building_type`, `new_build`) are `null`, `features` is limited to `{plot_area_m2, zoning, flood_zone, land_type}` (`zoning` currently always `null`; the zoning of the subject parcel is in `parcel.zoning`; `land_type` is `building_plot`, `project_land` or `other` and drives the first word of `summary`: "Building plot", "Development land" or "Land") and `summary` reads for example "Building plot of 694 m² in Kluisbergen, listed since 10-09-2026.". `neighbourhood.land_price_level {radius_m, count, price_per_m2_plot {p25, median, p75}, price_kind: asking_price, max_age_months: 24 | 60}` comes from the same land listings (same exclusions) of the last 24 months (a price level must be current; 2.5.1: with fewer than 10 usable listings within 24 months the window is widened to 60 months and `max_age_months` says which window was used, 24 or 60); only listings with a plot area and an asking price between 15 and 5,000 EUR per m² of plot enter the quartiles (`count`), so that agricultural land advertised as buildable and placeholder areas do not distort the level (items outside that band stay in the list with their own `price_per_m2_plot`); `price_level` and `epc_prices` are `null` (notice `housing_stats_not_available_for_land`). **No AVM for land**: `sections=basic,avm` stays HTTP 200 with `avm: null`, notice `{code: avm_not_available_for_land, section: avm}` and no charge for the option; `living_area_m2` may be omitted. `parcel` (the core for land: zoning, pre-emption right, several parcels via `capakeys`) and `price_map` (the housing map, not a land price map) are unchanged; billing is the basic package as usual.

```bash
curl -s -G "https://taxonapi.be/api/v1/partner/address" \
  -H "X-Api-Key: $TAXON_API_KEY" \
  -H "X-User-Ref: office-oudenaarde-02" \
  -H "X-Case-Ref: DOS-2026-0518" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Accept-Language: en" \
  --data-urlencode "address=Buissestraat 17, 9690 Kluisbergen" \
  --data-urlencode "type=land" \
  --data-urlencode "sections=basic,avm"
```

Response (live, 15-09-2026, test key, request `f0c372798b3f285b66ab4b584819be57`; the 2.5.1 fields `age_months` and `max_age_months: null` added), shortened to one comparable with one photo and the main parcel fields; `building_stock`, `safety`, `amenities`, `parcels`, `parcel_candidates` and `price_map` replaced by `"..."`:

```json
{
  "request_id": "f0c372798b3f285b66ab4b584819be57",
  "environment": "test",
  "address": {
    "input": "Buissestraat 17, 9690 Kluisbergen",
    "normalized": "Buissestraat 17, 9690 Kluisbergen",
    "box": null,
    "postal_code": "9690",
    "municipality": "Kluisbergen",
    "lat": 50.76716,
    "lon": 3.484313,
    "region": "VL",
    "nis_code": "45060",
    "geocoder": "geo.api.vlaanderen.be",
    "geocode_score": 0.95,
    "precision": "house_number"
  },
  "sections_delivered": [
    "basic",
    "parcel",
    "price_map"
  ],
  "basic": {
    "comparables": [
      {
        "type": "land",
        "type_label": "land",
        "transaction": "sale",
        "transaction_label": "for sale",
        "address_mode": "house_number",
        "radius_m": 5000,
        "count": 25,
        "excluded_subject_property": 0,
        "max_age_months": null,
        "low_sample": false,
        "items": [
          {
            "ref": "r_9965f5493e",
            "address": "Buissestraat 19, 9690 Kluisbergen",
            "distance_m": 25,
            "type": "land",
            "transaction": "sale",
            "price": 80000,
            "price_kind": "asking_price",
            "price_kind_label": "asking price",
            "price_per_m2": null,
            "price_per_m2_plot": 115,
            "living_area_m2": null,
            "plot_area_m2": 694,
            "bedrooms": null,
            "epc_label": null,
            "epc_kwh_m2": null,
            "epc_source": null,
            "year_built": null,
            "condition": null,
            "building_type": null,
            "new_build": null,
            "published": "2026-09-10",
            "days_online": 4,
            "last_seen": "2026-09-14",
            "age_months": 0,
            "status": "online",
            "status_label": "online",
            "source": "listing",
            "source_label": "listing",
            "features": {
              "plot_area_m2": 694,
              "zoning": null,
              "flood_zone": null,
              "land_type": "building_plot"
            },
            "summary": "Building plot of 694 m² in Kluisbergen, listed since 10-09-2026.",
            "thumbnail": {
              "url": "https://taxonapi.be/api/v1/marketexplorer/foto/fp_WzI3NDc1OTEsIjAxYTA0NzM2LTE5OGUtN2Q3ZC1iZjNjLWYyZDQ2NzliOTI3ZSIsMTc4OTQ2MzYyNiwicCJd.QB9ILrzzIwQxlyOV?w=160",
              "valid_until": "2026-09-15T09:13:46Z"
            },
            "photo_count": 13,
            "history": [
              {
                "date": "2026-08-28",
                "price": 80000,
                "event": "published",
                "label": "published"
              }
            ],
            "photos": [
              {
                "url": "https://taxonapi.be/api/v1/marketexplorer/foto/fp_WzI3NDc1OTEsIjAxYTA0NzM2LTE5OGUtN2Q3ZC1iZjNjLWYyZDQ2NzliOTI3ZSIsMTc4OTQ2MzYyNiwicCJd.QB9ILrzzIwQxlyOV?w=640",
                "download_token": "b17f02c8feb98b013e60",
                "valid_until": "2026-09-15T09:13:46Z"
              }
            ]
          }
        ]
      }
    ],
    "neighbourhood": {
      "sector": {
        "code": "45060A010",
        "name": "BUISESTRAAT",
        "level": "sector",
        "municipality": "Kluisbergen"
      },
      "building_stock": "...",
      "price_level": null,
      "epc_prices": null,
      "safety": "...",
      "land_price_level": {
        "radius_m": 5000,
        "count": 116,
        "price_per_m2_plot": {
          "p25": 126,
          "median": 160,
          "p75": 224
        },
        "price_kind": "asking_price",
        "max_age_months": 24
      }
    },
    "amenities": "..."
  },
  "parcel": {
    "capakey": "45043C0460/00X000",
    "region": "VL",
    "area_m2": 528.79,
    "cadastral_area_m2": 528.78,
    "fiscal_situation": "2027-01-01",
    "zoning": {
      "category": "residential",
      "label": "woongebieden",
      "plan": "gewestplan"
    },
    "preemption_right": {
      "status": "none",
      "source": "RVV thematic layer, right of pre-emption (Flanders)"
    },
    "source": "CadGIS PlanParcellaire (FPS Finance) / GRB Gbg - building at ground level (Digitaal Vlaanderen)",
    "main_parcel": "45043C0460/00X000",
    "parcels_count": 1,
    "total_area_m2": 528.79,
    "plot_area_source": "cadastre_main_parcel",
    "parcels": "...",
    "parcel_candidates": "..."
  },
  "avm": null,
  "price_map": "...",
  "billing": {
    "user_ref": "office-oudenaarde-02",
    "case_ref": "DOS-2026-0518",
    "charged": false,
    "pilot": false,
    "free_reason": "test",
    "price": {
      "package": 0.0,
      "avm": 0.0,
      "total": 0.0,
      "currency": "EUR",
      "excl_vat": true
    },
    "dedup_of": null,
    "dedup_valid_until": "2026-10-15T10:13:47+02:00",
    "month": "2026-09",
    "month_to_date": {
      "cases": 0,
      "amount": 0.0
    },
    "pilot_status": {
      "active": true,
      "quota": 200,
      "used": 0,
      "remaining": 200,
      "ends": "2026-11-03",
      "expired": false
    },
    "today": {
      "client_today": 10,
      "user_today": 1
    }
  },
  "notices": [
    {
      "code": "avm_not_available_for_land",
      "section": "avm",
      "message": "AVM not delivered: Taxon does not provide an automated valuation for land (type land); not charged."
    },
    {
      "code": "housing_stats_not_available_for_land",
      "section": "basic",
      "message": "price_level and epc_prices are housing statistics and are not delivered for land; see neighbourhood.land_price_level."
    },
    {
      "code": "price_map_sparse",
      "section": "price_map",
      "message": "price map: fewer than 30 listings in the subject neighbourhood (small sample)."
    },
    {
      "code": "test_environment",
      "section": null,
      "message": "test key: not invoiced"
    }
  ],
  "upstream_errors": {},
  "attribution": "References: Taxon (taxon.be)",
  "disclaimer": "Asking prices from listings, not notarial sale prices. Data for internal use per case only; building a derived database is prohibited. The valuer decides, the AVM is a support tool.",
  "generated_at": "2026-09-15T10:13:47+02:00"
}
```

## 3. Several parcels per property: first candidates, then capakeys

**Source of the parcel data (2.4.2).** The parcel section reads the current cadastral parcel plan of the FPS Finance (CadGIS PlanParcellaire, running fiscal situation, today 01-01-2027); the yearly INSPIRE snapshot (01-01-2026) is only the fallback on an outage or an empty answer. `fiscal_situation` (on `parcel`, on every `parcels[]` item and on every `parcel_candidates[]` item) gives the ISO date of the fiscal situation the parcel comes from; `source` names the layer. The date is the fiscal situation since which the current version of this parcel applies: a parcel that has not changed for years keeps an older date (for example 2019-01-01), while the plan it is read from is always the running fiscal situation. An older date therefore does not mean outdated data; only `2026-01-01` combined with an INSPIRE `source` marks the yearly snapshot fallback. A parcel split or merged after the last snapshot (for example 45043C0460/00R000 in Kluisbergen: one parcel of 921 m² in the snapshot, 460R 392 m² + 460X 529 m² in the current plan) is delivered in its current form.

A terraced house with a separate garage or garden parcel, a farm with meadows, a villa on two cadastral parcels: the point parcel at the address is then only part of the property. The API solves this in two steps.

**Step 1: request without `capakeys`.** The parcel section contains the point parcel (`main_parcel`, `parcels[]` with 1 item) and `parcel_candidates[]`: the adjacent parcels (at most 12), each with `capakey`, cadastral `area_m2`, `direction` (compass point relative to the main parcel, always `N`, `E`, `S` or `W` whatever the language, like `orientation_garden`), `built` (true/false, or `null` when the regional buildings layer was temporarily unavailable) and `distance_m`. Show that list to the user (for example "33011I0172/00G000, 40 m², south, built" = the garage) and let them tick what belongs to the property. There is no owner data; the user knows the case.

```bash
curl -s -G "https://taxonapi.be/api/v1/partner/address" \
  -H "X-Api-Key: $TAXON_API_KEY" -H "X-User-Ref: ag-0417" -H "X-Case-Ref: PT-2026-004512" -H "Accept-Language: en" \
  --data-urlencode "address=Rijselstraat 62, 8900 Ieper" \
  --data-urlencode "sections=basic" --data-urlencode "type=house" \
  | jq '.parcel | {main_parcel, parcel_candidates}'
```

Live response of 07-09-2026 (`request_id` b78e1f9e939f860da0e244ab59e2ba49):

```json
{
  "main_parcel": "33011I0172/00H000",
  "parcel_candidates": [
    {"capakey": "33011I0170/00C000", "area_m2": 319.23, "direction": "N", "built": true, "distance_m": 14},
    {"capakey": "33011I0172/00F000", "area_m2": 153.69, "direction": "W", "built": true, "distance_m": 23},
    {"capakey": "33011I0172/00G000", "area_m2": 40.1,   "direction": "S", "built": true, "distance_m": 28},
    {"capakey": "33011I0169/00K000", "area_m2": 2940.52, "direction": "W", "built": true, "distance_m": 49}
  ]
}
```

**Step 2: request again with `capakeys`** (the main parcel plus the ticked parcels, comma-separated, at most 10; the hyphen form is accepted). Same user, same address: the basic package delivered in step 1 is **not charged again** (notice `dedup` with `section: basic`; `billing.free_reason` is `dedup` only when nothing new is delivered, otherwise `null` (charged), `test` or `pilot`), even though the parcel section is rebuilt with the new parcels. When you add the AVM option in step 2, you only pay that option.

```bash
curl -s -G "https://taxonapi.be/api/v1/partner/address" \
  -H "X-Api-Key: $TAXON_API_KEY" -H "X-User-Ref: ag-0417" -H "X-Case-Ref: PT-2026-004512" -H "Accept-Language: en" \
  --data-urlencode "address=Rijselstraat 62, 8900 Ieper" \
  --data-urlencode "sections=basic,avm" --data-urlencode "type=house" \
  --data-urlencode "living_area_m2=140" --data-urlencode "bedrooms=3" \
  --data-urlencode "capakeys=33011I0172/00H000,33011I0172-00G000" \
  | jq '{parcel: (.parcel | {main_parcel, parcels_count, total_area_m2, zoning_combined, preemption_right_combined, parcels: [.parcels[] | {capakey, is_main, area_m2, distance_to_address_m}]}), avm_inputs: .avm.inputs_used, free_reason: .billing.free_reason}'
```

Live response of 07-09-2026 (`request_id` 2e88e0e1cfc66d6731aa1ca13f5ae83d, 6.8 s; `free_reason` is `dedup` here because this user had already received `avm` for the property earlier that day):

```json
{
  "parcel": {
    "main_parcel": "33011I0172/00H000",
    "parcels_count": 2,
    "total_area_m2": 669.02,
    "zoning_combined": {"category": "residential", "label": "woongebieden met cultureel, historische en/of esthetische waarde", "plan": "gewestplan"},
    "preemption_right_combined": {"status": "none", "parcels": []},
    "parcels": [
      {"capakey": "33011I0172/00H000", "is_main": true,  "area_m2": 628.92, "distance_to_address_m": 0},
      {"capakey": "33011I0172/00G000", "is_main": false, "area_m2": 40.1,   "distance_to_address_m": 26}
    ]
  },
  "avm_inputs": {"type": "house", "living_area_m2": 140.0, "year_built": null, "epc_label": null, "condition": null, "bedrooms": 3, "plot_area_m2": 669.02, "plot_area_source": "cadastre_2_parcels"},
  "free_reason": "dedup"
}
```

Rules and error paths:

- The section-level fields (`capakey`, `area_m2`, `zoning`, `preemption_right`, ...) stay those of the **main parcel**: the given parcel on which the address point falls, otherwise the first given parcel (then notice `main_parcel_not_in_capakeys` with the point parcel, so that you do not forget it). `zoning_combined` is one object when all parcels have the same zoning, otherwise a list with `parcels[]` per zoning. `preemption_right_combined.status` is `yes` as soon as one parcel lies in a perimeter (with the capakeys in `parcels[]` and the detail per parcel in `details`).
- The AVM (type house) uses `total_area_m2`; `avm.inputs_used.plot_area_source` says `cadastre_main_parcel`, `cadastre_<n>_parcels` (n = number of delivered parcels, 2 to 10, for example `cadastre_2_parcels`) or `partner`; the section field `parcel.plot_area_source` only carries the two cadastre values, `partner` only appears in `avm.inputs_used`. For an apartment no plot area goes to the model (not even with `capakeys`).
- `plot_area_m2` is a fallback: without `capakeys` and with your own total the AVM uses that (notice `plot_area_not_cadastral`); with `capakeys` the cadastre always wins.
- `422 capakey_invalid`: wrong form or more than 10 keys (`details[]` names the wrong parts). `422 capakey_too_far`: a parcel lies more than 2 km from the address; nothing is delivered or charged (abuse brake: parcels far from the address do not belong to the property). The parcel section is part of the basic package, so `capakeys` needs no extra section (`400 invalid_request` only when your plan does not allow the parcel section).
- A capakey that does not exist in the parcel plan: notice `capakey_not_found` (section parcel, message names the parcel) and `parcels_not_found[]`; the other parcels are delivered. When the parcel analysis fails for one parcel, only its cadastral area counts (notice `parcel_analysis_incomplete`).
- The same `capakeys` again within 30 days returns the stored answer from the ledger (`from_cache: true`); other `capakeys` give a fresh parcel section, also free (same property). The same `Idempotency-Key` with other `capakeys` = `409 idempotency_conflict`. `parcel_candidates`, `parcel_candidates_source` and `parcel_candidates_remark` are always present: `null` when `capakeys` was given; `parcel_candidates_remark` is also `null` when the neighbour lookup succeeded.
- Load: keep to the parcels of the case. Every request with `capakeys` does one cadastre query plus one parcel analysis per parcel (up to 10); a farm with 3 parcels takes 3 to 12 s; the first analysis of a parcel whose regional layers are cold can take up to 21 s (the budget of the parcel section). When that budget is exceeded the response is still 200, the parcel section is missing (`upstream_errors.parcel: timeout`, notice `section_missing`); the basic package is charged once: request again after a few seconds with a new `Idempotency-Key`; the repeat is free (dedup) and rebuilds the parcel section.

## 4. Price map (section `price_map`)

The basic package (`sections=basic`) includes the price map: asking-price levels per neighbourhood within 3,000 m of the address (at most 40 neighbourhoods) as **data plus GeoJSON geometry**. Taxon delivers no map images or tiles; you draw the polygons yourself with your own map library and licence (Leaflet, MapLibre, Mapbox, ...) and colour them by `price_per_m2_house` or `price_per_m2_apartment`.

```bash
curl -s -G "https://taxonapi.be/api/v1/partner/address" \
  -H "X-Api-Key: $TAXON_API_KEY" -H "X-User-Ref: ag-0417" -H "X-Case-Ref: PT-2026-004512" -H "Accept-Language: en" \
  --data-urlencode "address=Avenue Alphonse Allard 94, 1420 Braine-l'Alleud" \
  --data-urlencode "sections=basic" --data-urlencode "type=house" \
  | jq '.price_map | {radius_m, price_kind, reference_period, updated_at, subject_neighbourhood, n: (.neighbourhoods | length), first: (.neighbourhoods[0] | del(.geometry)), source}'
```

Live response of 07-09-2026 (`request_id` 88915700832ddc745b4355de3c15f332, 0.3 s; notice `price_map_sparse` because the subject neighbourhood has only 5 listings):

```json
{
  "radius_m": 3000,
  "price_kind": "asking_price",
  "reference_period": "2026-03-30",
  "updated_at": "2026-07-09T10:26:08+00:00",
  "subject_neighbourhood": "5906",
  "n": 40,
  "first": {"id": "5906", "name": "Saint-Sebastien", "municipality": "Braine-l'Alleud", "postal_code": "1420", "distance_m": 0, "is_subject": true,
            "price_per_m2_house": 2466.07, "price_per_m2_apartment": 2912.64, "listings_count_house": 3, "listings_count_apartment": 2, "low_sample": true},
  "source": "Taxon asking prices (advertised prices, not notarial sale prices)"
}
```

Block shape: `{radius_m: 3000, price_kind: "asking_price", reference_period (date of the price layer), updated_at (last rebuild of that layer), subject_neighbourhood (id string of the neighbourhood that contains the address), neighbourhoods[] (sorted by distance, at most 40, the subject neighbourhood always included), source}`; per neighbourhood `{id, name, municipality, postal_code, distance_m, is_subject, price_per_m2_house, price_per_m2_apartment (asking-price level per m² from the Taxon price layer of reference_period, null when the layer has no figure), listings_count_house, listings_count_apartment (number of Taxon listings of the last 24 months in that neighbourhood: a measure of how much current supply supports the figure, not the sample behind it; 0 = a reference-layer figure without recent Taxon listings), low_sample (fewer than 30 such listings), geometry}` where `geometry` is a GeoJSON Polygon or MultiPolygon (WGS84 lon/lat). Block size 20 to 50 KB; warm response under 0.5 s, the first request after a service restart can take up to 20 s. Drawing it with Leaflet:

```js
L.geoJSON(bundle.price_map.neighbourhoods.map(n => ({type: "Feature", geometry: n.geometry, properties: n})), {
  style: f => ({fillColor: colour(f.properties.price_per_m2_house), weight: f.properties.is_subject ? 3 : 1,
                dashArray: f.properties.low_sample ? "4 4" : null, fillOpacity: 0.5})
}).bindPopup(l => `${l.feature.properties.name}: ${l.feature.properties.price_per_m2_house ?? "n/a"} EUR/m²`).addTo(map);
```

Rules: `low_sample: true` = fewer than 30 Taxon listings of the last 24 months in that neighbourhood, show as indicative (for example hatched); notice `price_map_sparse` (section `price_map`) when the subject neighbourhood itself has fewer than 30 listings, the section is still delivered; when no neighbourhood with prices lies within 3 km the section is `null`, the notice `price_map_unavailable` is added, `upstream_errors.price_map` is `no_coverage`; no failure, the basic package stays charged. Show the `source` line next to the map. The geometry falls under the same usage rules as the rest of the bundle (per case, no derived map layer). The price map is part of the basic package: no separate price, the package price is in `billing.price.package` and in the CSV column `price_package`; dedup follows the package.

## 5. Photos

Each comparable carries at most 5 objects `photos[] {url, download_token, valid_until}`, main photo (facade) first, empty list without photos.

- `url` is a signed capability URL on taxonapi.be, currently `https://taxonapi.be/api/v1/marketexplorer/foto/<token>?w=640`. `w` is the server-side width: 160, 320 or 640 (other values are rounded up to the next one and capped at 640; without `w` the link also delivers 640 px), always JPEG; the original is never served. It works without a key, directly in an `<img>`, with `Cache-Control: public, max-age=1800, immutable`. Do not parse, do not compose yourself, fetch within the hour.
- `valid_until` (UTC) is 1 hour after issue. After expiry or manipulation the URL answers HTTP 404 with `text/plain` body `invalid_or_expired_token` (measured live: a changed token gives 404 immediately).
- On a repeated request (dedup or Idempotency-Key replay) the photo links are refreshed: new `url` and `valid_until`, same `download_token`. So re-request the bundle instead of storing links.
- `download_token` is a stable opaque identifier (20 hex) for your own bookkeeping or dedup; it fetches nothing.
- **Embedding in the report (2.5.0).** Fetch photos within the validity of the link (1 hour) and use them only for the case for which the request was made. Embedding them in the valuation report (PDF) of that case is allowed, always with a visible source line next to every photo (the `attribution` of the response, for example "References: Taxon (taxon.be)"), subject to the partner agreement (the partner indemnifies Taxon against claims of portals or agents concerning the photos). No republication, no storage outside that report, no bulk download; the resolution stays at most 640 px (`w=640`). Fetch the image server-side while composing the report; do not keep the link, it expires after one hour.

**Features, summary and thumbnail (2.2.0).** Each comparable also carries `features` (structured listing features: garage, parking_spaces, terrace and terrace_m2, garden and garden_m2, cellar, attic, floor, floors_count, elevator, kitchen, bathrooms, shower_rooms, toilets, heating, solar_panels, double_glazing, orientation_garden, renovation_year, inspections {electrical_compliant, asbestos_certificate, oil_tank}, flood_zone; every key present, `null` = unknown, enum values English in every language), `summary` (one sentence in Taxon's own words built from those fields only, in the language of `Accept-Language`; never listing text) and `photo_count` (total number of photos of the listing, `photos[]` stays capped at 5). `thumbnail {url, valid_until}` is the primary photo (front facade) at `w=160` under the same rules as the photo links (1 hour, refreshed on repeated requests, do not store); `photos[0]` is the same photo at 640 px; `null` without photos. Show `summary` as an introduction line and `features` as chips or a small table; treat `null` as "not stated", never as "no". Example: `"summary": "Terraced house of 156 m² on a plot of 259 m² with garage, garden (190 m²), terrace (34 m²) and cellar, 3 bedrooms, EPC B, built in 1975."` with `"features": {"garage": true, "parking_spaces": 3, "terrace": true, "terrace_m2": 34, "garden": true, "garden_m2": 190, "cellar": true, "attic": true, "floor": null, "floors_count": 4, "elevator": false, "kitchen": "equipped", "bathrooms": 1, "shower_rooms": null, "toilets": 2, "heating": "gas", "solar_panels": null, "double_glazing": true, "orientation_garden": null, "renovation_year": null, "inspections": {"electrical_compliant": false, "asbestos_certificate": null, "oil_tank": null}, "flood_zone": "none"}`, `"photo_count": 30` (live comparable of 07-09-2026).

## 6. Languages

`Accept-Language: nl` (default), `fr` or `en` sets the language of labels (`type_label`, `transaction_label`, `price_kind_label`, `status_label`, `source_label`, `history[].label`, `condition` of a comparable, `epc_source`, `building_stock.distribution[].label`, `amenities` labels, `confidence.label`), of notices and error messages, of `attribution` and `disclaimer`, of the source lines (`source`) and of the municipality name (one rule for `address`, comparables, `neighbourhood.sector`, `neighbourhood.safety` and `price_map`: Flanders Dutch, Wallonia French, Brussels Dutch for `nl` and French for `fr` and `en`; no exonyms, so Liège and Braine-l'Alleud also for `nl`). JSON keys and enum values (`sale`, `rent`, `asking_price`, `house_number`, `terraced`, `published`, ...) are always English. The zoning `label` is the text of the regional service (Dutch for VL, French for WAL) and has no English translation; `zoning.category` is an English enum value (`residential`, `residential_rural`, `residential_expansion`, `agricultural`, `industrial`, `nature`, `forest`, `park`, `recreation`, `community_facilities`, `extraction`, `weekend_residence`, `mixed`, `other`). Compass points (`garden_orientation`, `parcel_candidates[].direction`, `features.orientation_garden`) are language-independent (`N`, `NE`, `E`, ...). The order of `notices[]` is not significant.

Measured live on the same Walloon address (07-09-2026): `transaction_label` for sale / à vendre / te koop; `confidence.label` medium / moyenne / gemiddeld; `garden_orientation` SW in every language; `address.municipality` Braine-l'Alleud in every language (no exonym); `attribution` "References: Taxon (taxon.be)" / "Références : Taxon (taxon.be)" / "Referenties: Taxon (taxon.be)".

## 7. PHP (cURL, PHP 8)

```php
<?php
final class TaxonPartnerClient
{
    private const BASE = 'https://taxonapi.be/api/v1/partner/';

    public function __construct(private string $apiKey, private string $lang = 'en') {}

    /** @return array{status:int, headers:array<string,string>, body:array} */
    public function address(string $address, string $type, array $sections, string $userRef,
                            ?string $caseRef = null, array $extra = [], ?string $idemKey = null): array
    {
        $query = array_merge(['address' => $address, 'type' => $type, 'sections' => implode(',', $sections)], $extra);
        $headers = [
            'X-Api-Key: ' . $this->apiKey,
            'X-User-Ref: ' . $userRef,
            'Accept-Language: ' . $this->lang,
            'Idempotency-Key: ' . ($idemKey ?? bin2hex(random_bytes(16))),
        ];
        if ($caseRef !== null) { $headers[] = 'X-Case-Ref: ' . $caseRef; }
        return $this->get('address?' . http_build_query($query), $headers);
    }

    public function usage(string $month, bool $csv = false): array
    {
        $q = http_build_query(['month' => $month, 'format' => $csv ? 'csv' : 'json']);
        return $this->get('usage?' . $q, ['X-Api-Key: ' . $this->apiKey], !$csv);
    }

    private function get(string $path, array $headers, bool $json = true): array
    {
        $respHeaders = [];
        $ch = curl_init(self::BASE . $path);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER     => $headers,
            CURLOPT_CONNECTTIMEOUT => 5,
            CURLOPT_TIMEOUT        => 60,          // AVM + parcel can take 10-25 s
            CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$respHeaders) {
                if (str_contains($line, ':')) { [$k, $v] = explode(':', $line, 2); $respHeaders[strtolower(trim($k))] = trim($v); }
                return strlen($line);
            },
        ]);
        $raw = curl_exec($ch);
        if ($raw === false) { throw new RuntimeException('Taxon: ' . curl_error($ch)); }
        $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        curl_close($ch);
        $body = $json ? (json_decode($raw, true) ?? []) : ['csv' => $raw];
        return ['status' => $status, 'headers' => $respHeaders, 'body' => $body];
    }
}

// Use with retry on 429/503/5xx (same Idempotency-Key!)
$client = new TaxonPartnerClient(getenv('TAXON_API_KEY'), 'en');
$idem = bin2hex(random_bytes(16));
for ($attempt = 1; $attempt <= 3; $attempt++) {
    $r = $client->address("Avenue Alphonse Allard 94, 1420 Braine-l'Alleud", 'house', ['basic', 'avm'],
                          'ag-0417', 'PT-2026-004512', ['living_area_m2' => 165, 'year_built' => 1978, 'epc' => 'D', 'condition' => 'good'], $idem);
    if ($r['status'] === 200) { break; }
    if ($r['status'] === 429 || $r['status'] === 503) { sleep((int)($r['headers']['retry-after'] ?? $r['body']['retry_after'] ?? 5)); continue; }
    if ($r['status'] >= 500) { sleep(2 ** $attempt); continue; }
    throw new RuntimeException("Taxon {$r['status']} {$r['body']['error']} ({$r['body']['request_id']}): {$r['body']['message']}");
}
$bundle = $r['body'];
// Must be shown in the UI and in the report:
echo $bundle['attribution'], "\n", $bundle['disclaimer'], "\n";
echo 'Charged: ', $bundle['billing']['charged'] ? 'yes' : 'no (' . ($bundle['billing']['free_reason'] ?? '-') . ')', "\n";
foreach ($bundle['notices'] as $n) { echo 'notice: ', $n['code'], ' ', $n['section'] ?? '-', ' ', $n['message'], "\n"; }
foreach ($bundle['basic']['comparables'] as $block) foreach ($block['items'] as $c) {
    printf("%s %s | %s | %d m | %s EUR (%s) | %d m² | EPC %s\n", $block['transaction'], $block['type'],
        $c['address'], $c['distance_m'], number_format($c['price'], 0, ',', '.'), $c['price_kind'], $c['living_area_m2'], $c['epc_label'] ?? '-');
}
```

## 8. Python (requests)

```python
import os, time, uuid, requests

BASE = "https://taxonapi.be/api/v1/partner/"
KEY = os.environ["TAXON_API_KEY"]          # tx_test_... during the pilot tests

def taxon_address(address, type_, sections=("basic",), user_ref="ag-0417", case_ref=None, lang="en", **extra):
    headers = {
        "X-Api-Key": KEY,
        "X-User-Ref": user_ref,
        "Accept-Language": lang,
        "Idempotency-Key": str(uuid.uuid4()),   # reused on every retry below
    }
    if case_ref:
        headers["X-Case-Ref"] = case_ref
    params = {"address": address, "type": type_, "sections": ",".join(sections), **extra}
    for attempt in range(1, 4):
        r = requests.get(BASE + "address", headers=headers, params=params, timeout=(5, 60))
        if r.status_code == 200:
            return r.json()
        if r.status_code in (429, 503):
            body = r.json() if r.headers.get("Content-Type", "").startswith("application/json") else {}
            time.sleep(int(r.headers.get("Retry-After") or body.get("retry_after") or 5)); continue
        if r.status_code >= 500:
            time.sleep(2 ** attempt); continue
        err = r.json()
        raise RuntimeError(f"Taxon {r.status_code} {err['error']} ({err['request_id']}): {err['message']}")
    raise RuntimeError("Taxon: no answer after 3 attempts")

bundle = taxon_address("Avenue Alphonse Allard 94, 1420 Braine-l'Alleud", "house",
                       sections=("basic", "avm"), case_ref="PT-2026-004512",
                       living_area_m2=165, year_built=1978, epc="D", condition="good")

print(bundle["attribution"]); print(bundle["disclaimer"])            # must be shown
print("sections:", bundle["sections_delivered"], "| charged:", bundle["billing"]["charged"])
for n in bundle["notices"]:
    print("notice:", n["code"], n.get("section"), n["message"])
if bundle.get("avm"):
    a = bundle["avm"]
    print(f"AVM {a['value']:,} EUR, range {a['range']}, confidence {a['confidence']['label']}")
for section, reason in bundle["upstream_errors"].items():
    print("failure (not charged):", section, reason)

# Usage as CSV
csv = requests.get(BASE + "usage", headers={"X-Api-Key": KEY},
                   params={"month": "2026-09", "format": "csv"}, timeout=30)
open("taxon-usage-2026-09.csv", "wb").write(csv.content)
```

## 9. Error handling

Every error has the same body: `{"error", "message", "request_id", "charged": false}` plus, depending on the code, `details[] {field, issue}`, `retry_after`, `scope`, `limit`, `section`, `user_ref`, `reason`, `since` or `pilot`. `message` follows `Accept-Language`. No error is ever charged. Header and parameter errors are reported together in one `400 invalid_request` (`details[]` lists every wrong field, headers included).

| HTTP | error | Meaning | What the client does |
|---|---|---|---|
| 400 | `invalid_request` | Parameter or header invalid; `details[]` names the field (also a 1.x parameter name such as `adres`, a parameter sent twice, an unknown section, `capakeys` without the parcel section, an invalid `X-User-Ref`, `X-Case-Ref` or `Idempotency-Key`) | Fix the request; never retry as is |
| 400 | `user_ref_required` | `X-User-Ref` missing (and no alias) | Add the header |
| 400 | `user_ref_conflict` | Two or more user headers with different values | Send one header, preferably `X-User-Ref` |
| 401 | `missing_api_key`, `invalid_api_key` | Key missing or unknown | Configuration error; alert operations, do not retry |
| 403 | `key_revoked` | Key revoked | Switch to the new key |
| 403 | `ip_not_allowed` | IP outside the allowlist | Pass the new server IP to Taxon |
| 403 | `client_suspended` | Client suspended | Contact Taxon; do not retry |
| 403 | `section_not_allowed` | Section (`section`) not in your plan | Remove the section |
| 403 | `quota_exceeded` | Daily cap; `scope` `day` (key, `limit` 300; test key 20) or `user_day` (`user_ref`, `limit` 20); header `Retry-After` = seconds until midnight Europe/Brussels | Queue until `Retry-After`; other users keep working |
| 403 | `pilot_exhausted` | Pilot quota or period over; `pilot{}` | Contact Taxon |
| 403 | `user_suspended` | User suspended (`reason` `raster_scan`, `handmatig` or text, `since`); no Retry-After, no automatic expiry | Block that user in your UI; lifting via info@taxon.be with `user_ref` |
| 404 | `address_not_found` | No geocoder finds the address | Let the user correct the address |
| 404 | `not_found` | Unknown path (also 1.x paths `/adres`, `/verbruik`) | Fix the path |
| 405 | `method_not_allowed` | Not GET | Use GET |
| 409 | `idempotency_conflict` | Same `Idempotency-Key` for another request | Use a new key |
| 422 | `address_imprecise` | Found at street or municipality level only, or no postal code and no municipality given | Ask for the house number and postal code |
| 422 | `type_unsupported` | Type not supported for this address | Change the type |
| 422 | `capakey_invalid` | Form or more than 10 (`details[]`) | Fix the capakeys |
| 422 | `capakey_too_far` | A parcel more than 2 km away | Remove that parcel |
| 429 | `rate_limited` | Per-minute cap (application); `Retry-After` header and `retry_after` | Wait, retry with the same `Idempotency-Key` |
| 429 | `rate_limit_exceeded` | Per-minute cap (nginx); body `{"error": "rate_limit_exceeded", "retry_after": 60}`, no header, no `request_id` | Wait `retry_after` from the body, retry |
| 502 | `upstream_unavailable` | Basic section could not be delivered; `details[]` names the block | Up to 3 retries with back-off (2, 4, 8 s), same `Idempotency-Key` |
| 503 | `overloaded` | Overload guard; `Retry-After` | Wait `Retry-After`, retry with the same `Idempotency-Key` |
| 504 | `timeout` | Bundle longer than 40 s | Retry with the same `Idempotency-Key` |
| 500 | `internal_error` | Unexpected | Retry once, then report the `request_id` |

Rules you enforce in code:

- Client timeout at least 60 s; retry only on 429 (with `Retry-After` or `retry_after`), 503 and 5xx (502/504/500), always with the same `Idempotency-Key`; recognise a repeated answer by `X-Idempotent-Replay: true`; the same key with any other input (address, user, sections, AVM parameters, `capakeys` or language) gives 409 `idempotency_conflict`; `X-Request-ID` is not an idempotency key (nginx overwrites it). On `403 quota_exceeded` wait for `Retry-After`.
- Ignore unknown JSON fields and unknown notice codes; handle missing sections (`parcel`, `avm`, `price_map`) via `sections_delivered`, `notices[]` (deliberately not delivered: `living_area_required`, `parcel_not_found`, `avm_insufficient_data`, `price_map_unavailable`) and `upstream_errors` (failure `timeout` or `upstream_error`, notice `section_missing`; for `price_map` also `no_coverage`). Billing follows the package (2.4.0): a missing `parcel` or `price_map` leaves the basic package charged, a missing `avm` is not charged. `basic.comparables` is a list of blocks per type and transaction (`sale`, `rent`); a sub-block of `neighbourhood` can be `null`. On `upstream_errors.parcel: timeout` (cold parcel analysis) request again after a few seconds with a new `Idempotency-Key`; the basic package is not charged again (dedup) and the missing section is rebuilt free of charge.
- Parcels: show `parcel.parcels[]` and `total_area_m2` in the report when `capakeys` were sent, not only the section-level fields of the main parcel; `parcel_candidates[]` is a pick list for the user, not a statement about ownership. Never send `capakeys` from another case and drop candidates the user did not tick before step 2.
- Show `attribution` and `disclaimer` from the response next to the data and in the report; `amenities.attribution` next to the amenities; `source` next to parcel, building stock, safety and price map.
- Delete raw responses after at most 30 days (job); only the finished report stays in the case archive.
- Grid scans (consecutive house numbers or postal codes in a short time) suspend the user: `403 user_suspended` with `user_ref`, `reason` and `since`, no Retry-After, no automatic expiry; lifting only by Taxon (info@taxon.be); the suspension hits the user, not the client; 3+ suspended users within 24 h = `client_suspended` for the whole key. So never build a bulk or test loop over real addresses outside the test plan.
- `X-User-Ref` = the real user id, stable, character set `A-Z a-z 0-9 . _ : @ -` (at most 64; the API normalises to lower case); never one fixed value for all users (that is dedup abuse and leads to suspension).
- Log per request `request_id`, user, case, `sections_delivered`, `billing.charged`, `billing.price.package`, `billing.price.avm`, `billing.price.total`: then your bookkeeping matches `GET /usage` and the monthly invoice.

## 10. Idempotency

Send a fresh UUID as `Idempotency-Key` with every request and reuse it only for retries of that same request. The same key within 24 hours returns the stored answer byte for byte (same `request_id`, response header `X-Idempotent-Replay: true`) without a new charge and without counting against the daily cap; photo links in the replay get a fresh validity. The replay covers stored 200 responses; after an error (4xx or 5xx) the same key simply executes the request again (that is what a retry after 502/503/504 needs), and every executed attempt counts toward the daily caps as described in section 1. The same key with another address, user, section list, AVM parameter, `capakeys` or language gives `409 idempotency_conflict` (not charged): use a new key. Measured live on 08-09-2026: the replay of the main example (Doorniksestraat 40, Kortrijk) returned `request_id` df0cfcebf101496f99e7f01a0f14f227 with `X-Idempotent-Replay: true` in 80 ms, body byte for byte identical; the same key with another address gave 409 `idempotency_conflict` (not charged).

Dedup is a different mechanism (commercial, 30 days, per user and property, per package or option) and works without any header: the same user requesting the same property again gets `from_cache: true` and `free_reason: dedup` when all requested sections were already delivered, or notices `dedup` per section when only some were. A property is the geocoded point (rounded to about 1 m) plus the box number: another `type`, other AVM parameters, other `capakeys` or another language do not make another property. Dedup freezes nothing: such a repeat is rebuilt with the new input (fresh labels, fresh `inputs_used`) and stays free for the sections already delivered; `free_reason` is `dedup` whenever every delivered section had already been delivered (ledger hit or rebuild, also with a test key), otherwise `test`, `pilot` or `null` (charged) with notices `dedup` per section. A request without result (zero comparables, `free_reason: no_result`) starts no dedup window: the next request for the same property by the same user is rebuilt and charged normally when comparables are found (the AVM option delivered with it does keep its own window).

## 11. Pilot test plan (test key tx_test_)

Test key: at most 20 requests per day, never invoiced, response contains `"environment": "test"` and a notice `test_environment`. Spread the tests over a few days or ask for a temporarily higher test cap. The 20 count as described in section 1 (dedup hits and 404/422 address errors included; Idempotency-Key replays not). Expected values below were measured live on 07-09-2026 with a test key.

### Test addresses (3 per region)

| Region | Address | type | living_area_m2 | Expected |
|---|---|---|---|---|
| WAL | Avenue Alphonse Allard 94, 1420 Braine-l'Alleud (verified by Taxon) | house | 165 | region WAL, nis_code 25014, capakey 25744E0226/00_000, zoning plan "plan de secteur" label "Habitat", garden_orientation SW, notice avm_indicative_not_regionally_calibrated |
| WAL | Rue de Namur 23, 1300 Wavre | house | 140 | region WAL, nis_code 25112 |
| WAL | Boulevard Tirou 50, 6000 Charleroi | apartment | 85 | region WAL, nis_code 52011, comparables type apartment |
| BXL | Avenue Louise 200, 1050 Ixelles | apartment | 110 | region BXL, zoning plan GBP/PRAS, notice avm_indicative_not_regionally_calibrated |
| BXL | Rue Royale Sainte-Marie 22, 1030 Schaerbeek (verified by Taxon) | apartment | 95 | region BXL, nis_code 21015, capakey 21908E0259/00N000, garden_orientation null, avm.inputs_used.plot_area_m2 null |
| BXL | Avenue de Tervueren 150, 1150 Woluwe-Saint-Pierre | house | 220 | region BXL, nis_code 21019 |
| VL | Rijselstraat 60, 8900 Ieper (verified by Taxon) | house | 150 | region VL, nis_code 33011, geocoder geo.api.vlaanderen.be, zoning plan gewestplan, no indicative notice |
| VL | Kortrijksesteenweg 300, 9000 Gent | apartment | 90 | region VL, nis_code 44021 |
| VL | Mechelsesteenweg 100, 2018 Antwerpen | apartment | 75 | region VL, nis_code 11002 |

Three addresses (one per region) were verified by Taxon against BeSt; the others serve to test geocoding, region detection and the section logic. When a house number does not exist in BeSt (404 `address_not_found`), pick a neighbouring number in the same street (the charge is zero in test anyway).

### Scenarios (tick off)

| # | Scenario | Call | Expected |
|---|---|---|---|
| T01 | Health | `GET /health` without key | 200, `status: ok`, `versie: "2.5.1"` (service version, contract 2.0), `bundel: live`, `db: ok`; `POST /address` = 405 `method_not_allowed`; unknown path and the 1.x paths `/adres`, `/verbruik` = 404 `not_found` |
| T02 | No key | `GET /address` without `X-Api-Key` | 401 `missing_api_key` |
| T03 | Wrong key | `X-Api-Key: tx_test_0000...` | 401 `invalid_api_key`, same response time as T02 |
| T04 | No user | valid key, without `X-User-Ref` (and without alias) | 400 `user_ref_required` (message in the language of `Accept-Language`) |
| T04b | Deprecated alias | only `X-Gebruiker-Ref: ag-0417` or `X-Kantoor-Ref: AG-0417` | 200, `billing.user_ref: "ag-0417"` (alias works, same normalisation) |
| T04c | Alias conflict | `X-User-Ref: ag-0417` and `X-Kantoor-Ref: ag-0022` | 400 `user_ref_conflict`, `details[0].field: "X-User-Ref"`, not charged; both equal (also `AG-0417`) = 200 |
| T05 | Validation | `address=abc` (too short), `type=villa`, `living_area_m2=7`, `X-User-Ref: ag 04/17`, old name `adres=` | 400 `invalid_request` with `details[]` (all wrong fields in one response, headers included; `field` = `address`, `type`, `living_area_m2`, `X-User-Ref`, or `adres` with issue "Extra inputs are not permitted"; `:` and `@` are allowed in the user ref) |
| T06 | Basic WAL | Braine-l'Alleud, `sections=basic` | 200, `sections_delivered: ["basic", "parcel", "price_map"]` (basic package), `comparables` = list of blocks (sale and rent, each with `address_mode`) with <= 25 items, every `price_kind = asking_price`, `source = listing`, `epc_source = "as advertised"`, no portal name or link, `safety` with figures per 1,000, `building_stock.distribution` with categories residential/commerce_services/industry/agriculture/other, `upstream_errors: {}`, `environment: test` |
| T07 | Labels FR | same as T06 with `Accept-Language: fr` | `attribution = "Références : Taxon (taxon.be)"`, disclaimer in French, notices in French, `transaction_label: "à vendre"`, `confidence.label: "moyenne"` (with avm), `garden_orientation: "SW"` (with parcel) |
| T07b | Labels NL | same with `Accept-Language: nl` | `attribution = "Referenties: Taxon (taxon.be)"`, `address.municipality: "Braine-l'Alleud"`, `transaction_label: "te koop"` |
| T08 | AVM without living area | `sections=basic,avm` without `living_area_m2` | 200, `avm` not in `sections_delivered`, notice `{code: living_area_required, section: avm}`, `billing.price.avm` = 0 |
| T09 | AVM with living area | `sections=basic,avm&living_area_m2=165&year_built=1978&epc=D&condition=good` | 200, `avm.value`, `range`, `range_90`, `rental_value_range`, `confidence`, `price_basis: asking_price_model`, `inputs_used.condition: "good"`; notice `avm_indicative_not_regionally_calibrated` (WAL/BXL), not for VL |
| T10 | Parcel per region | `sections=basic` on one address per region (parcel is in the basic package) | `capakey` filled, `cadastral_area_m2`, `fiscal_situation` (2.4.2, for example `2027-01-01`), `zoning.plan` = plan de secteur / GBP/PRAS / gewestplan or RUP, `preemption_right.status` (`none`, `yes` or `unknown`), `source` filled, no flood field, no geometry; `main_parcel`, `parcels[]` (1 item), `parcel_candidates[]` (<= 12, each with `direction` N/E/S/W and `built`), `parcel_candidates_remark: null` |
| T10b | Several parcels (terraced house + garage) | `address=Rijselstraat 62, 8900 Ieper&sections=basic,avm&type=house&living_area_m2=140&bedrooms=3&capakeys=33011I0172/00H000,33011I0172-00G000` | 200, `parcels_count: 2`, `total_area_m2: 669.02`, `main_parcel: 33011I0172/00H000`, `parcels[1].distance_to_address_m: 26`, `parcel_candidates: null`, `avm.inputs_used.plot_area_m2: 669.02` and `plot_area_source: "cadastre_2_parcels"`, `bedrooms_filter.applied: true`; requested by the same user after step 1 of section 3 on Rijselstraat 62: notice `dedup` for `basic` (basic package), `free_reason: test` (test key; `avm` is new here, with a live key only `avm` is charged and `free_reason` is `null`; `dedup` only when nothing new is delivered; T10 itself uses Rijselstraat 60, another property) |
| T10c | Farm with 3 parcels | `address=Dikkebusstraat 5, 8954 Heuvelland&sections=basic&type=house&capakeys=33015C0813/00K000,33015C0812/00L000,33015C0795/00D000` | `parcels_count: 3`, `total_area_m2: 16058.83`, `zoning_combined` = one object (category `agricultural`, label Landbouwgebied, plan gewestplan), `preemption_right_combined.status: none`, `plot_area_source: cadastre_3_parcels`, duration < 15 s (measured 4.4 s) |
| T10d | Capakey error paths | (1) `capakeys=FOUT`; (2) 11 keys; (3) `capakeys=33011I0172/00H000,33015C0813/00K000` on Rijselstraat 62 Ieper; (4) `capakeys=33011I0172/00H000,33011I9999/00Z000`; (5) `capakeys=...` with `sections=basic` | (1) and (2) 422 `capakey_invalid` with `details[]` (issue "invalid: FOUT" resp. "11 given, maximum 10"); (3) 422 `capakey_too_far` ("lies 7474 m from the address"), not charged; (4) 200 with notice `capakey_not_found` and `parcels_not_found: ["33011I9999/00Z000"]`, 1 parcel delivered; (5) 200, parcel delivered (the parcel section is part of the basic package) |
| T10e | plot_area_m2 and bedrooms | T09 with `&plot_area_m2=850&bedrooms=3` (without capakeys) | `avm.inputs_used.plot_area_m2: 850.0`, `plot_area_source: "partner"`, notice `plot_area_not_cadastral`; block field `bedrooms_filter` (`applied` true with >= 8 comparables with 2-4 bedrooms, otherwise notice `bedrooms_filter_dropped`); `inputs_used.bedrooms: 3` |
| T10f | Price map | `sections=basic` (price map in the basic package) on Braine-l'Alleud, Ieper (Rijselstraat 60) and Schaerbeek | 200, `price_map` in `sections_delivered`, `radius_m: 3000`, `price_kind: asking_price`, `reference_period: "2026-03-30"`, `subject_neighbourhood` = the `id` of the item with `is_subject: true` (Braine-l'Alleud "5906" Saint-Sebastien, Ieper "7220" Ieper-Centrum, Schaerbeek "3110"), `neighbourhoods` 40 / 38 / 40 items each with `geometry.type` Polygon or MultiPolygon, exactly one `is_subject: true`; Braine-l'Alleud carries notice `price_map_sparse`; `billing.price.package` present; or `price_map: null` with notice `price_map_unavailable` and `upstream_errors.price_map: no_coverage` (package unchanged) |
| T11 | Dedup, clicking 3 times | T06 three times with the same `X-User-Ref` | 1st: `free_reason: "test"` (test key, price 0), 2nd and 3rd: `from_cache: true`, `free_reason: "dedup"`, `dedup_of` = request_id of the 1st, notice `dedup` with `section: null` |
| T12 | Dedup extra section | after T11: `sections=basic,avm&living_area_m2=165` | only `price.avm` > 0 (live) or marked as new; `price.package` = 0 and notice `dedup` with `section: basic` (basic package already delivered) |
| T13 | Other user | T06 with `X-User-Ref: ag-0022` | `dedup_of: null`, new charge |
| T14 | Idempotency | T09 twice with the same `Idempotency-Key` | identical body, same `request_id`, the second carries header `X-Idempotent-Replay: true` and does not count in `billing.today` |
| T14b | Idempotency conflict | same `Idempotency-Key` as T14 with another address | 409 `idempotency_conflict`, not charged |
| T15 | Address unknown | `address=Rue Inexistante 999, 1420 Braine-l'Alleud` | 404 `address_not_found`, `charged: false` |
| T15b | Address without house number | `address=Avenue Alphonse Allard, 1420 Braine-l'Alleud` | 422 `address_imprecise`, not charged |
| T16 | Section outside plan | `sections=basic,report` | 400 `invalid_request` (unknown section, `details[0].field: sections`) or 403 `section_not_allowed` with `section` (known but not in the plan; to test the 403 ask Taxon to restrict the plan of your test client temporarily; your plan is visible in `GET /usage` under `plan.sections_allowed`) |
| T17 | Daily cap test | 21st request on one day | 403 `quota_exceeded` with `scope: "day"`, `limit: 20` (test key; live: 300 per key, 20 per user with `scope: "user_day"` and `user_ref`), header `Retry-After` (until midnight); `billing.today.client_today` climbed to 20 before (counted: every request that reaches the address lookup, see section 1; Idempotency-Key replays are not) |
| T18 | Per-minute cap | 80 requests in 1 minute (agree with Taxon; costs test quota) | 429: from the app `rate_limited` with `Retry-After`, or from nginx `{"error":"rate_limit_exceeded","retry_after":60}` without header; no 5xx |
| T19 | Usage JSON | `GET /usage?month=<this month>` | `environment: test`, `per_user` contains ag-0417 and ag-0022 with `free_dedup` and `sections{package, avm}`, `sections_delivered{basic, parcel, avm, price_map}` (also `total.sections_delivered`), block `quality{error_pct, latency_p50_ms, latency_p95_ms, upstream_errors}`, `amount` 0; `?user_ref=AG-0417` filters (case-insensitive); `?maand=` = 400 with `details[0].field: maand`; block `plan {sections_allowed, max_per_minute, max_per_day, max_per_user_per_day, test_max_per_day, dedup_days, idempotency_hours, address_mode}` (your plan without prices); `pilot.expired` present; block `per_key[]` (2.4.1) with your test key (`environment: test`, `active: true`, `revoked_at: null`, sums = `total`) |
| T20 | Usage CSV | `format=csv` | `Content-Type: text/csv; charset=utf-8`, BOM, `;`, every field quoted, one row per request from T06-T14, 16 columns `request_id;timestamp;user_ref;case_ref;address;region;sections_requested;sections_delivered;charged;free_reason;dedup_of;price_package;price_avm;price_total;status_code;key_prefix` (`key_prefix` since 2.4.1), `Content-Disposition` file name `taxon-usage-<client>-<month>.csv` |
| T21 | Photo links and embedding | open a `photos[].url` from T06 before and after `valid_until`; fetch one photo server-side within the hour and embed it in a test report of that case | first 200 (image/jpeg, `Cache-Control: public, max-age=1800, immutable`), afterwards 404 `invalid_or_expired_token`; URL with a changed token = 404; the embedded photo carries a visible source line (the `attribution` of the response) next to it, is at most 640 px and appears only in the report of that case (2.5.0, section 5) |
| T22 | Features and thumbnail | from T06 an item of `basic.comparables[].items[]` | `features` with all 22 keys (unknown = `null`), `summary` a non-empty sentence in the requested language without portal names, `photo_count` >= `len(photos)`; when `thumbnail` is not `null`: GET of `thumbnail.url` = 200 `image/jpeg` and the token part of the URL equals that of `photos[0].url` (same photo, other width); with `type=land` (T24) `features` has exactly the 4 keys `plot_area_m2`, `zoning`, `flood_zone`, `land_type` |
| T23 | Timeout and retry | force a client timeout of 1 s, then retry with the same `Idempotency-Key` | the second attempt delivers the answer without double counting |
| T24 | Land VL | `address=Buissestraat 17, 9690 Kluisbergen&type=land&sections=basic,avm` (no `living_area_m2`) | 200, `sections_delivered: ["basic", "parcel", "price_map"]`, `avm: null`, notice `{code: avm_not_available_for_land, section: avm}`, `billing.price.avm` = 0 and no `living_area_required`; one comparables block with `type: land`, `transaction: sale`, `radius_m: 5000` (measured 25 items), every item `living_area_m2: null`, `plot_area_m2` and `price_per_m2_plot` filled when the listing carries a plot area, `features` with the 4 keys (`land_type` `building_plot`, `project_land` or `other`), `summary` starting with "Building plot", "Development land" or "Land", no agricultural land, meadows, woodland, industrial land or parking spaces among the items; `neighbourhood.land_price_level.count` > 0 with `p25 <= median <= p75` (measured 116 listings, median 160, after the patch of 15-09-2026), `price_level: null`, `epc_prices: null`, notice `housing_stats_not_available_for_land`; `parcel` and `price_map` as for a house |
| T24b | Land aliases and validation | `type=grond` on Rue de Fer 12, 5000 Namur (`Accept-Language: fr`), `type=terrain` on Rue Royale Sainte-Marie 22, 1030 Schaerbeek, `type=bouwgrond` on Rijselstraat 62, 8900 Ieper; `type=kasteel` | 200 with `comparables[0].type: "land"` and `type_label` in the requested language (`terrain` for fr, `grond` for nl, `land` for en), summaries in that language ("Terrain à bâtir de 1085 m² à Namur, proposé depuis le 10-09-2026."); `type=kasteel` = 400 `invalid_request` with `details[0].issue` "type must be one of house, apartment, land" |
| T24c | Land, thin area | `type=land` in a rural municipality (for example Hauptstrasse 2, 4760 Büllingen) | `radius_m: 10000` when fewer than 10 land listings lie within 5 km (measured: 25 within 10 km); with fewer than 10 within 10 km `low_sample: true` on the block and notice `low_sample` (section basic); 0 listings = notice `no_comparables`, package not charged (`free_reason: no_result`) |
| T24d | Land, all publication years (2.5.1) | `address=Buissestraat 17, 9690 Kluisbergen&type=land` | block `max_age_months: null`; every item carries `age_months` (integer >= 0, whole calendar months since `published`); the items of the last 24 months (`age_months` < 24) come first, sorted by distance, then the older ones, sorted by distance (at most 25 per block: in a dense area such as Kluisbergen all 25 are recent, measured 92 recent listings within 5 km; older listings appear where fewer than 25 recent ones exist, for example Klosterstraße 12, 4700 Eupen: measured 16-09-2026, 14 of the 25 items with `age_months` >= 24, pool within 5 km 11 recent and 16 older, `land_price_level.max_age_months` 60 with `count` 11; or Ooststraat 12, 8647 Lo-Reninge: 13 items of which 9 older, `max_age_months` 60 with `count` 10); `neighbourhood.land_price_level.max_age_months` is 24, or 60 when fewer than 10 usable listings lie within 24 months; with 0 listings the notice `no_comparables` reads "No land listings within 10 km (all publication years)." |

Closing the pilot test: send Taxon the list of `request_id`s per scenario; Taxon checks them against the ledger and confirms in writing. Then the live key is issued (after signature of the agreement).

## 12. Licence and usage rules

Summary of the contractual rules that the API also enforces technically; the agreement prevails.

- **Per case.** Show the data only to the valuer and include it only in the valuation report of the case for which the request was made; every request carries the real user (`X-User-Ref`) and the case (`X-Case-Ref`, contractually required, technically optional).
- **Retention at most 30 days** for raw responses, logs and caches; only the finished report (PDF) stays in the case archive.
- **No derived database**: do not merge, index or store data (comparables, statistics, parcels, price-map geometry) from several requests into your own database, map layer, index or price model; **no model feeding** (training, calibration, validation of any algorithm or AI system); **no resale or bulk** (no publishing, exporting, passing to third parties outside the report; no automated or systematic querying outside a case).
- **Photos** (2.5.0): fetched within the validity of the link, used only for the case, embedded only in the report of that case with a visible source line next to every photo (subject to the partner agreement, which includes the partner's indemnity of Taxon for claims of portals or agents); no republication, no storage outside that report, no bulk download; at most 640 px.
- **Attribution and disclaimer** visible in the UI and in every report (fields `attribution`, `disclaimer`); per-layer `source` and `amenities.attribution` next to that data; white-label only through a separate addendum.
- **EPC as advertised**, **no notarial prices** (never claim notary, VLABEL or transaction data), **human decision** (no decision with legal effect solely on the API or the AVM).
- **Key secret**, leak reported within 48 hours; on request (at most twice a year) an extract of case numbers against requests. **The agreement, prices and keys are confidential; this documentation is public.**

## 13. Checklist before go-live

- [ ] Key in a secret store, reachable from the backend only; IP allowlist passed to Taxon.
- [ ] `X-User-Ref` tied to your real user table (see the definition in section 1); `X-Case-Ref` to the case.
- [ ] Attribution and disclaimer visible on screen and in the report template (in every language you offer).
- [ ] Source lines per layer (`source`, `attribution`) next to the data.
- [ ] Deletion job: raw responses and logs with API data older than 30 days.
- [ ] Photos: fetched within the validity, embedded only in the report of the case with a visible source line per photo (2.5.0), no bulk download; no merging of comparables or price-map geometry across cases.
- [ ] Monitoring on `GET /health` and on the percentage of 429/502/503 per day.
- [ ] Monthly check of `GET /usage?format=csv` against the Taxon invoice.
- [ ] Key management in the partner dashboard (section 15): the person who shows a key once and rotates the live key is known; your client handles the notice `key_rotation_pending` (log it, switch keys within 7 days).
- [ ] Your bookkeeping reads `billing.price.package` and `billing.price.avm` (2.4.0) and the CSV columns `price_package`, `price_avm`, `price_total`; the basic package is one line per case, the AVM option a second one.

## 14. Changelog and mapping 1.x to 2.0

| Version | Date | Changes |
|---|---|---|
| 2.5.1 | 16-09-2026 | Land without age limit. With `type=land` the comparables are no longer limited to listings published within 24 months: all land listings for sale (same subtype exclusions as before) within 5 km (extended once to 10 km with fewer than 10 in total) are delivered, the listings of the last 24 months first (sorted by distance) and the older ones after them (sorted by distance), at most 25 per block, so that you can adjust older asking prices for the market evolution within the case (price indexation); the licence terms remain: no merging, indexing or storing across cases. New item field `age_months` (whole calendar months since `published`, only with `type: land`); block field `max_age_months` is `null` for land (stays 24 for house and apartment). `neighbourhood.land_price_level` keeps the last 24 months and, with fewer than 10 usable listings within 24 months, widens to 60 months; its `max_age_months` says which window was used (24 or 60). The notice `no_comparables` gets a land-specific message without "(< 24 months)". House and apartment unchanged. Additive: no changes to paths or existing fields. Service version 2.5.1. Patch of 16-09-2026 (same version 2.5.1): `days_online` is documented as nullable (`null` when the source recorded no online duration, mostly older offline listings; `last_seen` then equals `published`); the purpose of the older listings is worded as price indexation within the case (licence unchanged); test scenario T24d uses an example address that actually returns older listings; land responses stored under 2.5.0 are no longer replayed from the input cache. |
| 2.5.0 | 15-09-2026 | Land and photo rule. New value `type=land` (aliases `grond`, `terrain`, `bouwgrond`) for building plots: one comparables block with land listings for sale within 5 km (extended once to 10 km with fewer than 10; block field `low_sample` and notice `low_sample`), items with `plot_area_m2` and `price_per_m2_plot` (housing fields `null`, `features` limited to `plot_area_m2`, `zoning`, `flood_zone`), `neighbourhood.land_price_level` {radius_m, count, price_per_m2_plot {p25, median, p75}, price_kind, max_age_months} while `price_level` and `epc_prices` are `null` (notice `housing_stats_not_available_for_land`); no AVM for land (notice `avm_not_available_for_land`, section not delivered, not charged); `living_area_m2` optional; `parcel` and `price_map` unchanged; basic package price. Photo rule relaxed: photos may be embedded in the report of the case with a visible source line per photo, subject to the partner agreement (no republication, no storage outside that report, no bulk download, at most 640 px). Patch of 15-09-2026 (same version): land comparables and `land_price_level` are limited to building plots, project land and land without a more precise subtype (agricultural land, meadows, woodland, industrial land and parking spaces are excluded by subtype); `land_price_level` counts only listings between 15 and 5,000 EUR per m² of plot; new `LandFeatures` key `land_type` (`building_plot` | `project_land` | `other`), the first word of `summary` follows it (additive). Additive: no changes to paths or existing fields. Service version 2.5.0. |
| 2.4.2 | 14-09-2026 | Current cadastral parcel plan. The `parcel` section now reads the FPS Finance PlanParcellaire layer (running fiscal situation, today 01-01-2027) instead of the yearly INSPIRE snapshot (01-01-2026); the INSPIRE layer remains the fallback on an outage or an empty answer. New field `fiscal_situation` (ISO date of the fiscal situation of the parcel plan, for example `2027-01-01`; `2026-01-01` when the INSPIRE fallback answered; `null` when unknown) on `parcel`, on every `parcels[]` item and on every `parcel_candidates[]` item. `source` names the layer (`CadGIS PlanParcellaire (FPS Finance)` or `CadGIS INSPIRE (FPS Finance)`). Additive: no changes to paths, parameters or existing fields. Service version 2.4.2. |
| 2.4.1 | 08-09-2026 | Usage per API key: `/usage` gets the block `per_key[]` (per key: `prefix`, `label`, `environment`, `active`, `revoked_at`, `requests`, `cases`, `charged`, `sections`, `sections_delivered`, `amount`, `last_activity`; same filters as the rest of the response; keys without requests in the month are not listed, revoked keys with requests are). CSV: new last column `key_prefix` (16 columns). No changes to paths, parameters or existing fields. Service version 2.4.1 |
| 2.4.0 | 08-09-2026 | Basic package: `basic` + `parcel` + `price_map` are now one package per case; the AVM stays a separate option. `billing.price` is now `{package, avm, total, currency, excl_vat}`; `/usage` `total.sections`, `per_user[].sections` and `per_section` use `package`/`avm`, `total.sections_delivered` added; CSV columns `price_package`, `price_avm`, `price_total`. Requesting `parcel` or `price_map` separately is normalised to the package. Service version 2.4.0 |
| 2.3.0 | 08-09-2026 | Key management by the partner (section 15): keys shown exactly once in the partner dashboard on taxon.be (within 7 days, then the encrypted copy is destroyed); own test keys (at most 3 active, self-revocable); live-key rotation with a 7-day overlap during which responses made with the old key carry the notice `key_rotation_pending` (also in `/usage`), afterwards `403 key_revoked`; the first live key still comes from Taxon after signature; no changes to paths, parameters or fields; service version 2.3.0 |
| 2.2.2 | 07-09-2026 | Fixes after the first external integration test (Propteo): photo links always deliver a server thumbnail (640 px by default, never the original); `rental_value` always inside `rental_value_range`; `parcel_candidates`, `parcel_candidates_source` and `parcel_candidates_remark` always present (`null` with `capakeys`); `parcel_candidates[].direction` and `garden_orientation` language-independent (N/E/S/W); `zoning.category` is an English enum; `source` lines translated for fr/en; one municipality-name rule for the whole bundle (no exonyms); `new_build` only when `year_built` does not contradict it; French summaries agree in gender; header and parameter errors in one 400; `free_reason: dedup` also with a test key when every delivered section was already delivered; `pilot_status.expired` everywhere; `plan` block in `/usage`; CSV `Content-Type` with one charset; `Cache-Control` and `X-Content-Type-Options` once. Service version 2.2.2. |
| 2.2.1 | 07-09-2026 | Adversarial QA round (no key or path changes): box numbers `box 3`, `b3`, `app 3` recognised; an address without postal code and without municipality gives `422 address_imprecise`; `epc=A+` with a raw plus gives 400 (send `A%2B`); the input cache and the `Idempotency-Key` comparison cover all parameters and the language (other input = fresh bundle, still free for delivered sections; same key with other parameters = 409); `section_missing` for a sub-block of basic carries `section: basic` with the block name in the message; `/usage` rejects a parameter sent twice; price_map geometries always valid; concurrent identical requests in pilot/test count once. Service version 2.2.1. |
| 2.2.0 | 07-09-2026 | Every comparable carries `features` (structured listing features, unknown = `null`), `summary` (one sentence, nl/fr/en), `thumbnail {url, valid_until}` (primary photo 160 px, same photo as `photos[0]`) and `photo_count`; no listing text, no price change; service version 2.2.0 |
| 2.0.0 | 07-09-2026 | English contract: paths `/address`, `/usage`; header `X-User-Ref` (aliases `X-Gebruiker-Ref`, `X-Kantoor-Ref` deprecated), `X-Case-Ref` (alias `X-Dossier-Ref`), `Accept-Language: en`; all parameters, keys, enum values and notice codes English; error bodies with `charged`, `details[] {field, issue}`, scope `user_day`; new section `price_map` (service 2.1.0: notices `price_map_sparse`, `price_map_unavailable`, `upstream_errors.price_map: no_coverage`, a price key and a CSV column for the price map, replaced by the package keys in 2.4.0); `condition` now reaches the AVM; `/health` keeps its keys, `versie` shows the service version (2.1.0 at that time) |
| 1.2.0 | 07-09-2026 | Several parcels per property (`capakeys`, candidates, totals), plot-area fallback, bedrooms filter, new 422 errors and notices |
| 1.1.0 | 04-09-2026 | "office" became "user": user header required, office header deprecated alias; caps 20/300/60 fixed; watchdog suspends the user |
| 1.0.0 | 03-09-2026 | First contract: address bundle, usage, health; dedup 30 days; Idempotency-Key 24 h; QA round |

Mapping (old 1.x name -> 2.0 name). Old names no longer work, except the three header aliases.

| Where | 1.x | 2.0 |
|---|---|---|
| Paths | `/adres`, `/verbruik` | `/address`, `/usage` |
| Headers | `X-Gebruiker-Ref` (1.1), `X-Kantoor-Ref` (1.0), `X-Dossier-Ref` | `X-User-Ref`, `X-Case-Ref` (old names remain aliases) |
| Query /address | `adres`, `secties=basis`, `type=huis\|appartement`, `opp`, `bouwjaar`, `staat=nieuw\|zeer_goed\|goed\|matig\|te_renoveren`, `slaapkamers`, `opp_grond` | `address`, `sections=basic`, `type=house\|apartment\|land` (land 2.5.0), `living_area_m2`, `year_built`, `condition=excellent\|very_good\|good\|average\|poor`, `bedrooms`, `plot_area_m2` |
| Query /usage | `maand`, `formaat`, `gebruiker_ref` | `month`, `format`, `user_ref` |
| Top level | `omgeving`, `uit_cache`, `adres`, `secties_geleverd`, `basis`, `facturatie`, `meldingen`, `fouten_upstream`, `bronvermelding`, `gegenereerd_op` | `environment`, `from_cache`, `address`, `sections_delivered`, `basic`, `billing`, `notices`, `upstream_errors`, `attribution`, `generated_at` |
| address | `invoer`, `genormaliseerd`, `bus`, `postcode`, `gemeente`, `gewest`, `niscode`, `geocode_bron`, `precisie=huisnummer` | `input`, `normalized`, `box`, `postal_code`, `municipality`, `region`, `nis_code`, `geocoder`, `precision=house_number` |
| comparables block | `transactie=koop\|huur`, `adres_modus=huisnummer\|straat`, `straal_m`, `aantal`, `max_leeftijd_maanden`, `uitgesloten_eigen_pand`, `slaapkamers_filter {gevraagd, bereik, toegepast, aantal_binnen_filter}` | `transaction=sale\|rent`, `address_mode=house_number\|street`, `radius_m`, `count`, `max_age_months`, `excluded_subject_property`, `bedrooms_filter {requested, range, applied, count_within_filter}`; new in 2.5.0 (no 1.x equivalent): `low_sample` (land) |
| comparable item | `adres`, `afstand_m`, `prijs`, `prijs_soort=vraagprijs`, `prijs_per_m2`, `opp_wonen_m2`, `opp_grond_m2`, `slaapkamers`, `epc_kengetal_kwh_m2`, `epc_bron`, `bouwjaar`, `staat`, `bebouwing=open\|halfopen\|gesloten\|appartement`, `nieuwbouw`, `publicatie`, `dagen_online`, `laatst_gezien`, `bron=advertentie`, `historiek[] {datum, prijs, gebeurtenis=publicatie\|prijsdaling\|prijsstijging\|herpublicatie\|offline}`, `fotos[] {geldig_tot}` | `address`, `distance_m`, `price`, `price_kind=asking_price`, `price_per_m2` (per m² of living area; per month for `rent`, unlike `price_level.rent_per_m2_year`), `living_area_m2`, `plot_area_m2`, `bedrooms`, `epc_kwh_m2`, `epc_source`, `year_built`, `condition`, `building_type=detached\|semi_detached\|terraced\|apartment`, `new_build` (advertised as new build and `year_built`, when known, at most 3 years back; otherwise `false`), `published`, `days_online` (`null` when the source recorded no online duration, mostly older offline listings; `last_seen` then equals `published`), `last_seen`, `source=listing`, `history[] {date, price, event=published\|price_drop\|price_increase\|republished\|offline}`, `photos[] {valid_until}`; new in 2.2.0 (no 1.x equivalent): `features {...}`, `summary`, `thumbnail {url, valid_until}`, `photo_count`; new in 2.5.0: `price_per_m2_plot` (land) |
| neighbourhood (was `buurtstats`) | `sector {naam, niveau, gemeente}`, `gebouwenpark {niveau, peildatum, totaal, verdeling[] {cat, aantal}, bron}`, `prijsniveau {koop_m2, huur_m2_jaar, brutorendement_pct, brutorendement_p25_p75, straal_m, prijs_soort}`, `mediaan`, `epc_prijzen {labels {mediaan_m2, aantal}}`, `veiligheid {niveau, gemeente, jaar, woninginbraak_per_1000, misdrijven_per_1000, gewest_*, jaren[], bron}` | `sector {name, level, municipality}`, `building_stock {level, reference_date, total, distribution[] {category, count}, source}`, `price_level {sale_per_m2, rent_per_m2_year, gross_yield_pct, gross_yield_p25_p75, radius_m, price_kind}`, `median`, `epc_prices {labels {median_per_m2, count}}`, `safety {level, municipality, year, burglaries_per_1000, crimes_per_1000, region_*, years[], source}`; new in 2.5.0: `land_price_level {radius_m, count, price_per_m2_plot {p25, median, p75}, price_kind, max_age_months}` (land) |
| amenities (was `voorzieningen`) | `schaal`, `straal_m`, `aantal_pois`, `deelscores {openbaar_vervoer, zorg, winkels, sport_cultuur, onderwijs, groen} {aantal}`, `top_10[] {naam, afstand_m}`, `attributie` | `scale`, `radius_m`, `poi_count`, `sub_scores {public_transport, healthcare, shops, sport_culture, education, green_space} {count}`, `top_10[] {name, distance_m}`, `attribution` |
| parcel | `gewest`, `oppervlakte_m2`, `oppervlakte_kadastraal_m2`, `perceel_breedte_m`, `perceel_diepte_m`, `gevel_breedte_m`, `bebouwde_opp_m2`, `gebouwen_aantal`, `orientatie_tuin(_graden)`, `bestemming {categorie}`, `voorkooprecht {status=ja\|geen\|onbekend, bron, dekking_pct, percelen, percelen_onbekend}`, `opmerking`, `bron`, `hoofdperceel`, `percelen[] {hoofdperceel, afstand_tot_adres_m}`, `percelen_aantal`, `oppervlakte_totaal_m2`, `oppervlakte_kadastraal_totaal_m2`, `bestemming_gezamenlijk`, `voorkooprecht_gezamenlijk`, `percelen_niet_gevonden`, `opp_grond_bron`, `perceel_kandidaten[] {oppervlakte_m2, richting, bebouwd, afstand_m}`, `perceel_kandidaten_bron`, `perceel_kandidaten_opmerking` | `region`, `area_m2`, `cadastral_area_m2`, `fiscal_situation` (2.4.2; also in `parcels[]` and `parcel_candidates[]`, no 1.x equivalent), `width_m`, `depth_m`, `frontage_m`, `built_area_m2`, `buildings_count`, `garden_orientation(_deg)` (N/NE/E/... language-independent; `null` for apartments and for houses without a garden part), `zoning {category}` (category = English enum, label = official regional text), `preemption_right {status=yes\|none\|unknown, source, coverage_pct, parcels, parcels_unknown}`, `remark`, `source`, `main_parcel`, `parcels[] {is_main, distance_to_address_m}`, `parcels_count`, `total_area_m2`, `cadastral_total_area_m2`, `zoning_combined`, `preemption_right_combined`, `parcels_not_found`, `plot_area_source=cadastre_main_parcel\|cadastre_<n>_parcels` (n = 2 to 10; `partner` only in `avm.inputs_used`), `parcel_candidates[] {area_m2, direction=N\|E\|S\|W, built, distance_m}`, `parcel_candidates_source`, `parcel_candidates_remark` |
| avm | `waarde`, `vork`, `vork_90`, `huurwaarde`, `huurwaarde_vork`, `betrouwbaarheid`, `n_comparables(_500m, _1km)`, `invoer_gebruikt {opp_wonen_m2, bouwjaar, staat, slaapkamers, opp_grond_m2, opp_grond_bron}`, `prijs_soort=vraagprijs` | `value`, `range`, `range_90`, `rental_value` (always inside `rental_value_range`), `rental_value_range`, `confidence {score, label, fsd_pct}` (label follows fsd_pct: high up to 14, medium up to 24, low above), `comparables_count(_500m, _1km)`, `inputs_used {living_area_m2, year_built, condition, bedrooms, plot_area_m2, plot_area_source}`, `price_basis=asking_price_model`, `model` (model and calibration version: currently v3.1 for Flanders, regionally calibrated; v3.6 for Wallonia and Brussels, indicative) |
| billing (was `facturatie`) | `gebruiker_ref`, `dossier_ref`, `aangerekend`, `reden_gratis=geen_resultaat\|fout\|pilot_uitgeput`, `prijs {basis, totaal, munt, excl_btw}`, `dedup_van`, `dedup_geldig_tot`, `maand`, `verbruik_maand_tot_nu {dossiers, bedrag}`, `pilot_stand {actief, gebruikt, resterend, einde, verlopen}`, `dag {klant_vandaag, gebruiker_vandaag}` | `user_ref`, `case_ref`, `charged`, `free_reason=no_result\|error\|pilot_exhausted`, `price {package, avm, total, currency, excl_vat}` (2.4.0), `dedup_of`, `dedup_valid_until`, `month`, `month_to_date {cases, amount}`, `pilot_status {active, used, remaining, ends, expired}`, `today {client_today, user_today}` |
| notices (was `meldingen`) | `{code, sectie, bericht}`; `opp_required`, `avm_indicatief`, `geen_comparables`, `adres_straatniveau`, `geocoder_deels_onbereikbaar`, `gewest_conflict`, `eigen_pand_uitgesloten`, `hoofdperceel_niet_in_capakeys`, `perceel_analyse_onvolledig`, `opp_grond_niet_kadastraal_bevestigd`, `slaapkamers_filter_losgelaten`, `sectie_ontbreekt` (`upstream_fout`), `test_omgeving`, `pilot_uitgeput` | `{code, section, message}`; `living_area_required`, `avm_indicative_not_regionally_calibrated`, `no_comparables`, `address_street_level`, `geocoder_partially_unavailable`, `region_conflict`, `subject_property_excluded`, `main_parcel_not_in_capakeys`, `parcel_analysis_incomplete`, `plot_area_not_cadastral`, `bedrooms_filter_dropped`, `section_missing` (`upstream_error`), `test_environment`, `pilot_exhausted`; new in 2.3.0 (no 1.x equivalent): `key_rotation_pending`; new in 2.5.0: `avm_not_available_for_land`, `low_sample`, `housing_stats_not_available_for_land` |
| upstream_errors keys | `basis`, `basis.comparables`, `basis.buurtstats`, `basis.voorzieningen`, `buurtstats.gebouwenpark`, `buurtstats.veiligheid`, `buurtstats.prijsniveau`, `buurtstats.epc_prijzen`; value `upstream_fout` | `basic`, `basic.comparables`, `basic.neighbourhood`, `basic.amenities`, `neighbourhood.building_stock`, `neighbourhood.safety`, `neighbourhood.price_level`, `neighbourhood.epc_prices`; value `upstream_error` |
| error bodies | `aangerekend`, `details[] {veld, fout}`, `sectie`, `gebruiker_ref`, `reden`, `sinds`, `limiet`, `scope=gebruiker_day`; `gebruiker_ref_required`, `gebruiker_ref_conflict` | `charged`, `details[] {field, issue}`, `section`, `user_ref`, `reason`, `since`, `limit`, `scope=user_day`; `user_ref_required`, `user_ref_conflict` |
| /usage | `klant`, `maand`, `omgeving`, `gebruiker_ref`, `totaal {opvragingen, dossiers, aangerekend, gratis_dedup, gratis_pilot, gratis_storing, secties, bedrag, munt, excl_btw, duur_ms_gem}`, `per_gebruiker[] {secties_geleverd, laatste_activiteit}`, `per_dag[] {dag}`, `per_sectie {geleverd, aangerekend, bedrag}`, `kwaliteit {fout_pct, latentie_p50_ms, latentie_p95_ms, upstream_fouten}`, `gegenereerd_op` | `client`, `month`, `environment`, `user_ref`, `total {requests, cases, charged, free_dedup, free_pilot, free_error, sections, amount, currency, excl_vat, duration_ms_avg}`, `per_user[] {sections_delivered, last_activity}`, `per_key[] {prefix, label, environment, active, revoked_at, requests, cases, charged, sections, sections_delivered, amount, last_activity}` (2.4.1, internal name `per_sleutel`, no 1.x equivalent), `per_day[] {day}`, `per_section {delivered, charged, amount}` (2.4.0: keys `package`, `avm`; `total.sections {package, avm}`, `total.sections_delivered`), `quality {error_pct, latency_p50_ms, latency_p95_ms, upstream_errors}`, `generated_at` |
| CSV header | `request_id;tijdstip;gebruiker;dossier_ref;adres;gewest;secties_gevraagd;secties_geleverd;aangerekend;reden_gratis;dedup_van;prijs_basis;prijs_parcel;prijs_avm;prijs_totaal;status_code` | `request_id;timestamp;user_ref;case_ref;address;region;sections_requested;sections_delivered;charged;free_reason;dedup_of;price_package;price_avm;price_total;status_code;key_prefix` (2.4.0; `key_prefix` 2.4.1, internal name `sleutel_prefix`); file `taxon-usage-<client>-<month>.csv` |
## 15. Managing your keys

Since 08-09-2026 you manage your keys yourself in the partner dashboard on taxon.be (https://taxon.be/api_partner, section "Management"). Every action there is logged (who, when, from which IP address) and reported to Taxon; a key value never appears in a log or an e-mail.

| Action | How it works |
|---|---|
| Show a key once | A new key is not sent by e-mail. The dashboard shows it exactly once ("Show key once"), within 7 days after creation; copy it into your secret store right away. Until that moment Taxon keeps an encrypted copy only: after the first display, or after 7 days, the copy is destroyed and the key cannot be shown again. Keys issued before 08-09-2026 cannot be shown; request a new one or create one yourself. |
| Test keys | Create them yourself ("Create test key", with a label), at most 3 active test keys; revoke them yourself ("Revoke": the key stops working immediately, `401 invalid_api_key`). |
| First live key | Issued by Taxon after the signed agreement, ready for you to show once in your dashboard. You cannot create a first live key yourself (`live_key_not_allowed`); the "Request live key" form in the dashboard mails Taxon. |
| Rotate a live key | "Rotate live key" creates a new live key (show it once, put it in production). The previous live key keeps working for 7 days; during that period every `/address` and `/usage` response made with the old key carries the notice `key_rotation_pending` (`section: null`, message with the end date). After 7 days the old key is refused (`403 key_revoked`, afterwards `401 invalid_api_key`). A live key is never revoked from the dashboard: rotate it, or ask Taxon to revoke it immediately. |
| Leak or suspected abuse | Rotate (live) or revoke (test) immediately and notify info@taxon.be when the old key must stop at once instead of after 7 days. |

Error codes of the dashboard actions (shown as text in the dashboard, never on `/address` or `/usage`): `key_already_revealed`, `reveal_expired`, `not_revealable`, `key_limit_reached`, `live_key_not_allowed`, `not_self_revocable`.

Rotation procedure (recommended): 1. rotate in the dashboard and show the new key once; 2. put the new key in your secret store and deploy; 3. check that your responses no longer carry `key_rotation_pending`; 4. the old key expires by itself after 7 days.
